@linxin666/dsh-remote-web-ui 0.1.20 → 0.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.i18n.yaml +2 -2
- package/README.md +56 -26
- package/README.zh.md +20 -15
- package/assets/mobile-icon-192.png +0 -0
- package/assets/mobile-icon-512.png +0 -0
- package/assets/mobile-service-worker.js +64 -0
- package/lib/client.js +272 -47
- package/lib/client.js.map +1 -1
- package/lib/index.js +316 -60
- package/lib/mobile.js +487 -56
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +28 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +147 -6
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +13 -2
- package/lib/types/client/RemoteSettingsCard.d.ts +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +1 -1
- package/lib/types/client/UpdateEntry.d.ts.map +1 -1
- package/lib/types/client/UpdateEntry.js +36 -3
- package/lib/types/client/UpdatePanel.d.ts.map +1 -1
- package/lib/types/client/UpdatePanel.js +2 -2
- package/lib/types/client/deep-link.js +1 -1
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -0
- package/lib/types/gate.d.ts +9 -0
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +17 -4
- package/lib/types/index.d.ts +25 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +30 -13
- package/lib/types/mobile/PairRequiredView.d.ts +7 -0
- package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
- package/lib/types/mobile/PairRequiredView.js +28 -0
- package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
- package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
- package/lib/types/mobile/PairRequiredView.test.js +24 -0
- package/lib/types/mobile/api.d.ts +10 -0
- package/lib/types/mobile/api.d.ts.map +1 -1
- package/lib/types/mobile/api.js +4 -0
- package/lib/types/mobile/index.js +14 -1
- package/lib/types/mobile/messages.d.ts +24 -0
- package/lib/types/mobile/messages.d.ts.map +1 -1
- package/lib/types/mobile/messages.js +60 -23
- package/lib/types/mobile/messages.test.js +49 -1
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +86 -0
- package/lib/types/mobile/pairing.d.ts +31 -0
- package/lib/types/mobile/pairing.d.ts.map +1 -0
- package/lib/types/mobile/pairing.js +62 -0
- package/lib/types/mobile/views/App.d.ts +12 -5
- package/lib/types/mobile/views/App.d.ts.map +1 -1
- package/lib/types/mobile/views/App.js +34 -7
- package/lib/types/mobile/views/App.test.d.ts +2 -0
- package/lib/types/mobile/views/App.test.d.ts.map +1 -0
- package/lib/types/mobile/views/App.test.js +52 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +34 -11
- package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
- package/lib/types/mobile/views/SessionListView.js +30 -4
- package/lib/types/mobile/views/SessionListView.test.js +58 -1
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
- package/lib/types/mobile/views/WorkspaceView.js +4 -2
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +10 -1
- package/lib/types/mobile-pwa.d.ts +6 -0
- package/lib/types/mobile-pwa.d.ts.map +1 -0
- package/lib/types/mobile-pwa.js +17 -0
- package/lib/types/mobile-routes.d.ts +5 -8
- package/lib/types/mobile-routes.d.ts.map +1 -1
- package/lib/types/mobile-routes.js +115 -30
- package/lib/types/pairing-access.d.ts +28 -0
- package/lib/types/pairing-access.d.ts.map +1 -0
- package/lib/types/pairing-access.js +26 -0
- package/lib/types/pairing.d.ts +27 -0
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +68 -0
- package/lib/types/pairing.persist.test.d.ts +2 -0
- package/lib/types/pairing.persist.test.d.ts.map +1 -0
- package/lib/types/pairing.persist.test.js +128 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +23 -3
- package/lib/types/tunnel.d.ts +1 -0
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +7 -2
- package/lib/types/update.d.ts +4 -4
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +35 -12
- package/package.json +12 -12
- package/src/client/PluginSettingsCard.tsx +229 -18
- package/src/client/RemoteEntry.tsx +12 -2
- package/src/client/RemoteSettingsCard.tsx +3 -2
- package/src/client/UpdateEntry.tsx +36 -4
- package/src/client/UpdatePanel.tsx +2 -0
- package/src/client/deep-link.ts +1 -1
- package/src/client/locales.ts +4 -0
- package/src/client/remote.module.css +26 -0
- package/src/client/settings-card.module.css +90 -1
- package/src/gate.ts +17 -3
- package/src/index.ts +46 -15
- package/src/mobile/PairRequiredView.test.tsx +28 -0
- package/src/mobile/PairRequiredView.tsx +58 -0
- package/src/mobile/api.ts +14 -1
- package/src/mobile/index.tsx +15 -1
- package/src/mobile/messages.test.ts +52 -1
- package/src/mobile/messages.ts +65 -22
- package/src/mobile/mobile-styles.ts +86 -0
- package/src/mobile/pairing.ts +77 -0
- package/src/mobile/views/App.test.tsx +62 -0
- package/src/mobile/views/App.tsx +53 -6
- package/src/mobile/views/ChatView.tsx +33 -10
- package/src/mobile/views/SessionListView.test.tsx +69 -1
- package/src/mobile/views/SessionListView.tsx +58 -5
- package/src/mobile/views/WorkspaceView.tsx +4 -2
- package/src/mobile-api.ts +9 -1
- package/src/mobile-pwa.ts +19 -0
- package/src/mobile-routes.ts +123 -30
- package/src/pairing-access.ts +36 -0
- package/src/pairing.persist.test.ts +145 -0
- package/src/pairing.ts +70 -1
- package/src/routes.ts +23 -3
- package/src/tunnel.ts +7 -2
- package/src/update.ts +42 -14
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* ui-plugin-config PluginCard in a self-contained slice.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { useState, type ReactNode } from 'react'
|
|
11
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
|
12
12
|
import type { CardShell } from './settings-form.ts'
|
|
13
13
|
import css from './settings-card.module.css'
|
|
14
14
|
|
|
@@ -56,6 +56,12 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
56
56
|
* already provides the selection.
|
|
57
57
|
*/
|
|
58
58
|
alwaysOpen?: boolean
|
|
59
|
+
/**
|
|
60
|
+
* Hide the save/discard footer: cards whose body applies its own changes
|
|
61
|
+
* immediately (an embedded external settings section) have no staged
|
|
62
|
+
* edits, so the footer would sit there permanently disabled.
|
|
63
|
+
*/
|
|
64
|
+
hideFooter?: boolean
|
|
59
65
|
/** The plugin's controls. */
|
|
60
66
|
children: ReactNode
|
|
61
67
|
}
|
|
@@ -140,6 +146,9 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
140
146
|
<div className={css.body}>
|
|
141
147
|
{!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
|
|
142
148
|
{props.children}
|
|
149
|
+
{props.hideFooter === true
|
|
150
|
+
? null
|
|
151
|
+
: (
|
|
143
152
|
<div className={css.footer}>
|
|
144
153
|
{state.failed
|
|
145
154
|
? (
|
|
@@ -165,6 +174,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
165
174
|
{props.t(!state.saving ? 'settings.save' : 'settings.saving')}
|
|
166
175
|
</button>
|
|
167
176
|
</div>
|
|
177
|
+
)}
|
|
168
178
|
</div>
|
|
169
179
|
)
|
|
170
180
|
: null}
|
|
@@ -245,6 +255,210 @@ export function ValueField(props: FieldProps & {
|
|
|
245
255
|
)
|
|
246
256
|
}
|
|
247
257
|
|
|
258
|
+
const NON_SKIN_BODY_MARKERS = new Set(['dshSkinCenter', 'dshSidebarCollapsed'])
|
|
259
|
+
// 检测使用使用皮肤,用了皮肤退回原生select样式,防止样式冲突。默认外观下使用优化后的select样式。
|
|
260
|
+
function isSkinActive(): boolean {
|
|
261
|
+
const datasetList = Object.keys(document.body.dataset)
|
|
262
|
+
const isActive = datasetList.some(key => key.startsWith('dsh') && !NON_SKIN_BODY_MARKERS.has(key))
|
|
263
|
+
return isActive
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
interface SelectOption {
|
|
267
|
+
value: string
|
|
268
|
+
label: string
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const SELECT_CLOSE_MS = 100
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The shared dual-mode select control. While an appearance skin is active it
|
|
275
|
+
* renders the legacy native `<select>` untouched, so element-level skin
|
|
276
|
+
* selectors keep working; under the default appearance it renders a
|
|
277
|
+
* self-drawn `role="listbox"` popup whose open/close is transition-animated.
|
|
278
|
+
* Staged cards reach it through BooleanField/ChoiceField; immediate-apply
|
|
279
|
+
* editors (the side-card prefs) bind it directly through onEdit.
|
|
280
|
+
* 双模式下拉框:皮肤激活时用原生 select,默认外观用自绘动画弹层。
|
|
281
|
+
*/
|
|
282
|
+
export function SelectField(props: {
|
|
283
|
+
id: string
|
|
284
|
+
options: ReadonlyArray<SelectOption>
|
|
285
|
+
value: string
|
|
286
|
+
disabled: boolean
|
|
287
|
+
invalid: boolean
|
|
288
|
+
onEdit: (text: string) => void
|
|
289
|
+
}) {
|
|
290
|
+
const { id, options, value } = props
|
|
291
|
+
const [open, setOpen] = useState(false)
|
|
292
|
+
const [closing, setClosing] = useState(false)
|
|
293
|
+
const [phase, setPhase] = useState<'initial' | 'open'>('initial')
|
|
294
|
+
const [activeIndex, setActiveIndex] = useState(0)
|
|
295
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
296
|
+
const wrapRef = useRef<HTMLDivElement | null>(null)
|
|
297
|
+
const popupRef = useRef<HTMLDivElement | null>(null)
|
|
298
|
+
|
|
299
|
+
const currentIndex = () => {
|
|
300
|
+
const index = options.findIndex(option => option.value === value)
|
|
301
|
+
return index >= 0 ? index : 0
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const close = useCallback(() => {
|
|
305
|
+
if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
|
|
306
|
+
setClosing(true)
|
|
307
|
+
closeTimer.current = setTimeout(() => {
|
|
308
|
+
setClosing(false)
|
|
309
|
+
setOpen(false)
|
|
310
|
+
}, SELECT_CLOSE_MS)
|
|
311
|
+
}, [])
|
|
312
|
+
|
|
313
|
+
const openPopup = () => {
|
|
314
|
+
if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
|
|
315
|
+
setActiveIndex(currentIndex())
|
|
316
|
+
setPhase('initial')
|
|
317
|
+
setClosing(false)
|
|
318
|
+
setOpen(true)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const commit = (index: number) => {
|
|
322
|
+
const option = options[index]
|
|
323
|
+
if (option) props.onEdit(option.value)
|
|
324
|
+
close()
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const onTriggerClick = () => {
|
|
328
|
+
if (props.disabled) return
|
|
329
|
+
if (open && !closing) close()
|
|
330
|
+
else openPopup()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
334
|
+
if (props.disabled) return
|
|
335
|
+
const count = options.length
|
|
336
|
+
switch (event.key) {
|
|
337
|
+
case 'ArrowDown':
|
|
338
|
+
case 'ArrowUp':
|
|
339
|
+
case 'Enter':
|
|
340
|
+
case ' ':
|
|
341
|
+
event.preventDefault()
|
|
342
|
+
if (!open) {
|
|
343
|
+
openPopup()
|
|
344
|
+
} else if (!closing) {
|
|
345
|
+
if (event.key === 'ArrowDown') setActiveIndex(index => (index + 1) % count)
|
|
346
|
+
else if (event.key === 'ArrowUp') setActiveIndex(index => (index - 1 + count) % count)
|
|
347
|
+
else commit(activeIndex)
|
|
348
|
+
}
|
|
349
|
+
break
|
|
350
|
+
case 'Escape':
|
|
351
|
+
if (open) {
|
|
352
|
+
event.preventDefault()
|
|
353
|
+
event.stopPropagation()
|
|
354
|
+
close()
|
|
355
|
+
}
|
|
356
|
+
break
|
|
357
|
+
case 'Tab':
|
|
358
|
+
if (open) close()
|
|
359
|
+
break
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
useEffect(() => () => {
|
|
364
|
+
if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
|
|
365
|
+
}, [])
|
|
366
|
+
useLayoutEffect(() => {
|
|
367
|
+
if (open && !closing && phase === 'initial') {
|
|
368
|
+
void popupRef.current?.offsetHeight
|
|
369
|
+
setPhase('open')
|
|
370
|
+
}
|
|
371
|
+
}, [open, closing, phase])
|
|
372
|
+
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
if (!open) return
|
|
375
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
376
|
+
const target = event.target
|
|
377
|
+
if (target instanceof Node && !wrapRef.current?.contains(target)) close()
|
|
378
|
+
}
|
|
379
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
380
|
+
return () => document.removeEventListener('pointerdown', onPointerDown)
|
|
381
|
+
}, [open, close])
|
|
382
|
+
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
if (props.disabled && open) close()
|
|
385
|
+
}, [props.disabled, open, close])
|
|
386
|
+
|
|
387
|
+
// 使用了皮肤则退回原生select样式防止样式冲突。
|
|
388
|
+
if (isSkinActive()) {
|
|
389
|
+
return (
|
|
390
|
+
<select
|
|
391
|
+
id={id}
|
|
392
|
+
className={css.select}
|
|
393
|
+
value={value}
|
|
394
|
+
disabled={props.disabled}
|
|
395
|
+
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
396
|
+
>
|
|
397
|
+
{options.map(option => (
|
|
398
|
+
<option key={option.value} value={option.value}>{option.label}</option>
|
|
399
|
+
))}
|
|
400
|
+
</select>
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const label = options.find(option => option.value === value)?.label ?? ''
|
|
405
|
+
const popupClass = closing
|
|
406
|
+
? `${css.selectPopup} ${css.selectPopupClose}`
|
|
407
|
+
: phase === 'open'
|
|
408
|
+
? `${css.selectPopup} ${css.selectPopupOpen}`
|
|
409
|
+
: css.selectPopup
|
|
410
|
+
return (
|
|
411
|
+
<div className={css.selectWrap} ref={wrapRef}>
|
|
412
|
+
<button
|
|
413
|
+
type="button"
|
|
414
|
+
id={id}
|
|
415
|
+
className={`${css.select} ${css.selectButton}`}
|
|
416
|
+
disabled={props.disabled}
|
|
417
|
+
aria-haspopup="listbox"
|
|
418
|
+
aria-expanded={open}
|
|
419
|
+
aria-activedescendant={open ? `${id}-o${activeIndex}` : undefined}
|
|
420
|
+
aria-invalid={props.invalid || undefined}
|
|
421
|
+
onClick={onTriggerClick}
|
|
422
|
+
onKeyDown={onKeyDown}
|
|
423
|
+
>
|
|
424
|
+
<span className={css.selectLabel}>{label}</span>
|
|
425
|
+
<svg
|
|
426
|
+
width="14"
|
|
427
|
+
height="14"
|
|
428
|
+
viewBox="0 0 14 14"
|
|
429
|
+
fill="none"
|
|
430
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
431
|
+
className={open ? `${css.selectChevron} ${css.selectChevronOpen}` : css.selectChevron}
|
|
432
|
+
aria-hidden="true"
|
|
433
|
+
>
|
|
434
|
+
<path
|
|
435
|
+
d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
|
|
436
|
+
fill="currentColor"
|
|
437
|
+
/>
|
|
438
|
+
</svg>
|
|
439
|
+
</button>
|
|
440
|
+
{open
|
|
441
|
+
? (
|
|
442
|
+
<div className={popupClass} role="listbox" ref={popupRef}>
|
|
443
|
+
{options.map((option, index) => (
|
|
444
|
+
<div
|
|
445
|
+
key={option.value}
|
|
446
|
+
id={`${id}-o${index}`}
|
|
447
|
+
role="option"
|
|
448
|
+
aria-selected={option.value === value}
|
|
449
|
+
className={`${css.selectOption}${option.value === value ? ` ${css.selectOptionSelected}` : ''}${index === activeIndex && !closing ? ` ${css.selectOptionActive}` : ''}`}
|
|
450
|
+
onClick={() => { commit(index) }}
|
|
451
|
+
>
|
|
452
|
+
{option.label}
|
|
453
|
+
</div>
|
|
454
|
+
))}
|
|
455
|
+
</div>
|
|
456
|
+
)
|
|
457
|
+
: null}
|
|
458
|
+
</div>
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
|
|
248
462
|
/** A staged boolean field: 继承 / 开 / 关. */
|
|
249
463
|
export function BooleanField(props: FieldProps & {
|
|
250
464
|
/** Copy for the inherit option. */
|
|
@@ -274,17 +488,18 @@ export function BooleanField(props: FieldProps & {
|
|
|
274
488
|
)
|
|
275
489
|
: null}
|
|
276
490
|
</div>
|
|
277
|
-
<
|
|
491
|
+
<SelectField
|
|
278
492
|
id={props.id}
|
|
279
|
-
|
|
493
|
+
options={[
|
|
494
|
+
{ value: '', label: props.inheritLabel },
|
|
495
|
+
{ value: 'true', label: props.onLabel },
|
|
496
|
+
{ value: 'false', label: props.offLabel },
|
|
497
|
+
]}
|
|
280
498
|
value={props.text}
|
|
281
499
|
disabled={props.disabled}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
<option value="true">{props.onLabel}</option>
|
|
286
|
-
<option value="false">{props.offLabel}</option>
|
|
287
|
-
</select>
|
|
500
|
+
invalid={props.invalid}
|
|
501
|
+
onEdit={props.onEdit}
|
|
502
|
+
/>
|
|
288
503
|
<p className={css.hint}>{props.hint}</p>
|
|
289
504
|
</div>
|
|
290
505
|
)
|
|
@@ -317,18 +532,14 @@ export function ChoiceField(props: FieldProps & {
|
|
|
317
532
|
)
|
|
318
533
|
: null}
|
|
319
534
|
</div>
|
|
320
|
-
<
|
|
535
|
+
<SelectField
|
|
321
536
|
id={props.id}
|
|
322
|
-
|
|
537
|
+
options={[{ value: '', label: props.inheritLabel }, ...props.choices]}
|
|
323
538
|
value={props.text}
|
|
324
539
|
disabled={props.disabled}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
{props.choices.map(choice => (
|
|
329
|
-
<option key={choice.value} value={choice.value}>{choice.label}</option>
|
|
330
|
-
))}
|
|
331
|
-
</select>
|
|
540
|
+
invalid={props.invalid}
|
|
541
|
+
onEdit={props.onEdit}
|
|
542
|
+
/>
|
|
332
543
|
<p className={props.invalid ? css.invalid : css.hint}>
|
|
333
544
|
{props.invalid ? props.invalidLabel : props.hint}
|
|
334
545
|
</p>
|
|
@@ -57,6 +57,10 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
|
|
|
57
57
|
useEffect(() => { stateRef.current = state }, [state])
|
|
58
58
|
const [copied, setCopied] = useState(false)
|
|
59
59
|
const eventSource = useRef<EventSource | undefined>(undefined)
|
|
60
|
+
// Generation counter for the open flow: closing (or re-opening) the panel
|
|
61
|
+
// bumps it, so an in-flight issue() that resolves after a close does not
|
|
62
|
+
// spawn a stray EventSource.
|
|
63
|
+
const openSeq = useRef(0)
|
|
60
64
|
|
|
61
65
|
// The current workspace (the recent-workspace projection the shell's New
|
|
62
66
|
// Session flow targets) — the deep-link target for the phone.
|
|
@@ -104,8 +108,13 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
|
|
|
104
108
|
}, [workspaceId])
|
|
105
109
|
|
|
106
110
|
const openPanel = useCallback(async (): Promise<void> => {
|
|
111
|
+
const seq = ++openSeq.current
|
|
107
112
|
setOpen(true)
|
|
108
113
|
const next = await mint()
|
|
114
|
+
// A close (or re-open) during the await invalidates this issue: skip the
|
|
115
|
+
// state write and the stream so a panel closed mid-mint neither leaks an
|
|
116
|
+
// EventSource nor resurrects a stale QR.
|
|
117
|
+
if (seq !== openSeq.current) return
|
|
109
118
|
setState(next)
|
|
110
119
|
// Live status: the desktop panel mirrors the pairing service state. The
|
|
111
120
|
// stream makes sense in the ready state and on the lan-required banner —
|
|
@@ -143,6 +152,7 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
|
|
|
143
152
|
}, [mint])
|
|
144
153
|
|
|
145
154
|
const closePanel = useCallback(() => {
|
|
155
|
+
openSeq.current += 1
|
|
146
156
|
closeEventSource()
|
|
147
157
|
setOpen(false)
|
|
148
158
|
}, [closeEventSource])
|
|
@@ -222,13 +232,13 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
|
|
|
222
232
|
)
|
|
223
233
|
}
|
|
224
234
|
|
|
225
|
-
/** The trigger: an icon
|
|
235
|
+
/** The trigger: an icon-only control with a persistent accessible label. */
|
|
226
236
|
function TooltipAnchor({ wide, label, onClick }: { wide: boolean; label: string; onClick: () => void }) {
|
|
227
237
|
return (
|
|
228
238
|
<button
|
|
229
239
|
type="button"
|
|
230
240
|
className={css.trigger}
|
|
231
|
-
data-wide={wide ?
|
|
241
|
+
data-wide={wide ? 'wide' : 'rail'}
|
|
232
242
|
aria-label={label}
|
|
233
243
|
title={label}
|
|
234
244
|
onClick={onClick}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The remote-control settings card: pairing security and device limits.
|
|
3
|
-
* Registers into the `
|
|
4
|
-
*
|
|
3
|
+
* Registers into the `web-ui.plugin.item` child slot the Web UI plugin group
|
|
4
|
+
* renders, bound to the `remote-web-ui` settings namespace.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
@@ -140,6 +140,7 @@ export function RemoteSettingsCard(props: RemoteSettingsCardProps) {
|
|
|
140
140
|
t={t}
|
|
141
141
|
titleKey="settings.title"
|
|
142
142
|
descriptionKey="settings.description"
|
|
143
|
+
defaultOpen={false}
|
|
143
144
|
state={state}
|
|
144
145
|
onSave={props.save}
|
|
145
146
|
onDiscard={props.discard}
|
|
@@ -29,14 +29,31 @@ export interface UpdateEntryProps {
|
|
|
29
29
|
export function UpdateEntry({ wide, t }: UpdateEntryProps) {
|
|
30
30
|
const [open, setOpen] = useState(false)
|
|
31
31
|
const [view, setView] = useState<UpdateView>({ kind: "checking" })
|
|
32
|
+
const [updateAvailable, setUpdateAvailable] = useState(false)
|
|
32
33
|
const runToken = useRef(0)
|
|
34
|
+
const availabilityToken = useRef(0)
|
|
35
|
+
const mounted = useRef(false)
|
|
36
|
+
|
|
37
|
+
const probeAvailability = useCallback(async (): Promise<void> => {
|
|
38
|
+
const token = ++availabilityToken.current
|
|
39
|
+
try {
|
|
40
|
+
const status = await fetchUpdateStatus()
|
|
41
|
+
if (token === availabilityToken.current) {
|
|
42
|
+
setUpdateAvailable(status.mode === "npm" && status.outdated)
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
if (token === availabilityToken.current) setUpdateAvailable(false)
|
|
46
|
+
}
|
|
47
|
+
}, [])
|
|
33
48
|
|
|
34
49
|
const check = useCallback(async (): Promise<void> => {
|
|
50
|
+
const availabilityCheck = ++availabilityToken.current
|
|
35
51
|
setView({ kind: "checking" })
|
|
36
52
|
let status: UpdateStatus
|
|
37
53
|
try {
|
|
38
54
|
status = await fetchUpdateStatus()
|
|
39
55
|
} catch (error) {
|
|
56
|
+
if (availabilityCheck === availabilityToken.current) setUpdateAvailable(false)
|
|
40
57
|
// HTTP 404: the update route is not mounted — the host process runs an
|
|
41
58
|
// older plugin build (client refreshed, host did not). Restarting dsh
|
|
42
59
|
// web loads the new plugin; a plain network failure gets the generic
|
|
@@ -48,6 +65,9 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
|
|
|
48
65
|
setView({ kind: "error", message: t("update.offline"), detail: t("update.offlineDetail") })
|
|
49
66
|
return
|
|
50
67
|
}
|
|
68
|
+
if (availabilityCheck === availabilityToken.current) {
|
|
69
|
+
setUpdateAvailable(status.mode === "npm" && status.outdated)
|
|
70
|
+
}
|
|
51
71
|
if (status.error === "registry-unreachable") {
|
|
52
72
|
setView({ kind: "result", status })
|
|
53
73
|
return
|
|
@@ -60,6 +80,7 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
|
|
|
60
80
|
const token = ++runToken.current
|
|
61
81
|
try {
|
|
62
82
|
const result = await runUpdate()
|
|
83
|
+
if (result.ok && mounted.current) setUpdateAvailable(false)
|
|
63
84
|
if (token !== runToken.current) return
|
|
64
85
|
setView({ kind: "done", result })
|
|
65
86
|
} catch {
|
|
@@ -78,8 +99,18 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
|
|
|
78
99
|
setOpen(false)
|
|
79
100
|
}, [])
|
|
80
101
|
|
|
81
|
-
//
|
|
82
|
-
useEffect(() =>
|
|
102
|
+
// Check once after mount without opening the panel or starting an update.
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
mounted.current = true
|
|
105
|
+
void probeAvailability()
|
|
106
|
+
return () => {
|
|
107
|
+
mounted.current = false
|
|
108
|
+
runToken.current++
|
|
109
|
+
availabilityToken.current++
|
|
110
|
+
}
|
|
111
|
+
}, [probeAvailability])
|
|
112
|
+
|
|
113
|
+
const updateLabel = updateAvailable ? t("update.availableLabel") : t("update.label")
|
|
83
114
|
|
|
84
115
|
return (
|
|
85
116
|
<>
|
|
@@ -87,8 +118,9 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
|
|
|
87
118
|
type="button"
|
|
88
119
|
className={css.trigger}
|
|
89
120
|
data-wide={wide ? undefined : "rail"}
|
|
90
|
-
|
|
91
|
-
|
|
121
|
+
data-update-available={updateAvailable ? "true" : undefined}
|
|
122
|
+
aria-label={updateLabel}
|
|
123
|
+
title={updateLabel}
|
|
92
124
|
onClick={openPanel}
|
|
93
125
|
>
|
|
94
126
|
<IconDownloadOutline16 size={wide ? 16 : 18} />
|
|
@@ -63,6 +63,7 @@ export function UpdatePanel({ t, view, onClose, onRecheck }: UpdatePanelProps) {
|
|
|
63
63
|
<p className={css.updateStatus}>
|
|
64
64
|
{t("update.updating", { name: anchorName(status) ?? "", version: anchorLatest(status) ?? "" })}
|
|
65
65
|
</p>
|
|
66
|
+
<p className={css.updateDetail}>{t("update.cooldownNotice")}</p>
|
|
66
67
|
<PackageList status={status} />
|
|
67
68
|
</div>
|
|
68
69
|
)}
|
|
@@ -136,6 +137,7 @@ function ResultBody({ t, status }: { t: TranslateNS<"remote">; status: UpdateSta
|
|
|
136
137
|
<p className={css.updateDetail}>
|
|
137
138
|
{anchor !== undefined ? t("update.foundDetail", { name: anchor, version: latest ?? "" }) : ""}
|
|
138
139
|
</p>
|
|
140
|
+
<p className={css.updateDetail}>{t("update.cooldownNotice")}</p>
|
|
139
141
|
</div>
|
|
140
142
|
)
|
|
141
143
|
}
|
package/src/client/deep-link.ts
CHANGED
|
@@ -95,7 +95,7 @@ async function runAccept(token: string, page: PageSurface): Promise<void> {
|
|
|
95
95
|
// mobile surface can open the intended workspace instead of losing the
|
|
96
96
|
// QR context at this navigation boundary.
|
|
97
97
|
if (isMobileSurface()) {
|
|
98
|
-
url.pathname = '/m'
|
|
98
|
+
url.pathname = '/m/'
|
|
99
99
|
page.navigate(`${url.pathname}${url.search}${url.hash}`)
|
|
100
100
|
} else {
|
|
101
101
|
page.reload()
|
package/src/client/locales.ts
CHANGED
|
@@ -74,6 +74,7 @@ export const zh = {
|
|
|
74
74
|
'settings.invalidNumber': '请输入数字,留空则使用默认值。',
|
|
75
75
|
// 更新面板(侧边栏更新按钮)。
|
|
76
76
|
'update.label': '检查更新',
|
|
77
|
+
'update.availableLabel': '发现新版本,检查更新',
|
|
77
78
|
'update.title': '检查更新',
|
|
78
79
|
'update.checking': '正在检查远程仓库中的新版本…',
|
|
79
80
|
'update.upToDate': '当前已是最新版本',
|
|
@@ -81,6 +82,7 @@ export const zh = {
|
|
|
81
82
|
'update.found': '发现新版本',
|
|
82
83
|
'update.foundDetail': '{name} 有新版本 v{version},正在自动更新…',
|
|
83
84
|
'update.updating': '正在更新 {name} 至 v{version},可能需要 1-2 分钟…',
|
|
85
|
+
'update.cooldownNotice': '本次更新由你主动触发,将跳过 pnpm 的 24 小时发布冷却期(供应链保护)直接安装所选版本。',
|
|
84
86
|
'update.updatingTitle': '正在自动更新…',
|
|
85
87
|
'update.done': '更新完成',
|
|
86
88
|
'update.doneDetail': '所有组件已更新到最新版本。',
|
|
@@ -187,6 +189,7 @@ export const en = {
|
|
|
187
189
|
'settings.invalidNumber': 'Enter a number, or leave blank to use the default.',
|
|
188
190
|
// Update panel (the sidebar update trigger).
|
|
189
191
|
'update.label': 'Check for updates',
|
|
192
|
+
'update.availableLabel': 'New version available. Check for updates',
|
|
190
193
|
'update.title': 'Check for updates',
|
|
191
194
|
'update.checking': 'Checking the remote repository for newer releases…',
|
|
192
195
|
'update.upToDate': 'Everything is up to date',
|
|
@@ -194,6 +197,7 @@ export const en = {
|
|
|
194
197
|
'update.found': 'A new version is available',
|
|
195
198
|
'update.foundDetail': '{name} has a newer release v{version}; updating automatically…',
|
|
196
199
|
'update.updating': 'Updating {name} to v{version}; this may take 1-2 minutes…',
|
|
200
|
+
'update.cooldownNotice': 'This user-initiated update bypasses pnpm\'s 24-hour release cooldown (supply-chain protection) and installs the selected version immediately.',
|
|
197
201
|
'update.updatingTitle': 'Updating automatically…',
|
|
198
202
|
'update.done': 'Update complete',
|
|
199
203
|
'update.doneDetail': 'All components are up to date.',
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
/* Trigger: settings row height beside the trigger row (wide), or the 36px
|
|
22
22
|
rail circle; the seat's owner (ui-sidebar) lays out the geometry. */
|
|
23
23
|
.trigger {
|
|
24
|
+
position: relative;
|
|
24
25
|
flex: none;
|
|
25
26
|
display: inline-flex;
|
|
26
27
|
align-items: center;
|
|
@@ -37,6 +38,30 @@
|
|
|
37
38
|
transition: background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
.trigger[data-update-available]::after {
|
|
42
|
+
position: absolute;
|
|
43
|
+
top: 4px;
|
|
44
|
+
right: 4px;
|
|
45
|
+
width: 8px;
|
|
46
|
+
height: 8px;
|
|
47
|
+
box-sizing: border-box;
|
|
48
|
+
border: 2px solid var(--dsw-alias-bg-layer-1);
|
|
49
|
+
border-radius: 50%;
|
|
50
|
+
background: var(--dsw-alias-brand-primary);
|
|
51
|
+
content: '';
|
|
52
|
+
pointer-events: none;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.trigger[data-wide='wide'] {
|
|
56
|
+
flex: 1 1 auto;
|
|
57
|
+
justify-content: flex-start;
|
|
58
|
+
width: auto;
|
|
59
|
+
min-width: 0;
|
|
60
|
+
border-radius: 8px;
|
|
61
|
+
gap: 8px;
|
|
62
|
+
padding: 0 10px;
|
|
63
|
+
}
|
|
64
|
+
|
|
40
65
|
.trigger:hover:not(:disabled) {
|
|
41
66
|
background: var(--dsw-alias-interactive-bg-hover);
|
|
42
67
|
color: var(--dsw-alias-label-primary);
|
|
@@ -429,6 +454,7 @@
|
|
|
429
454
|
display: flex;
|
|
430
455
|
align-items: center;
|
|
431
456
|
gap: 6px;
|
|
457
|
+
min-width: 0;
|
|
432
458
|
}
|
|
433
459
|
|
|
434
460
|
.entryRow[data-rail='rail'] {
|
|
@@ -290,6 +290,92 @@
|
|
|
290
290
|
border-color: var(--dsw-alias-label-error);
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
.selectWrap {
|
|
294
|
+
position: relative;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.selectButton {
|
|
298
|
+
appearance: none;
|
|
299
|
+
width: 100%;
|
|
300
|
+
text-align: left;
|
|
301
|
+
cursor: pointer;
|
|
302
|
+
align-items: center;
|
|
303
|
+
justify-content: space-between;
|
|
304
|
+
gap: 8px;
|
|
305
|
+
display: flex;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.selectLabel {
|
|
309
|
+
min-width: 0;
|
|
310
|
+
overflow: hidden;
|
|
311
|
+
text-overflow: ellipsis;
|
|
312
|
+
white-space: nowrap;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.selectChevron {
|
|
316
|
+
color: var(--dsw-alias-label-tertiary);
|
|
317
|
+
flex: none;
|
|
318
|
+
transition: transform 0.16s;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.selectChevronOpen {
|
|
322
|
+
transform: rotate(180deg);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.selectPopup {
|
|
326
|
+
position: absolute;
|
|
327
|
+
top: calc(100% + 4px);
|
|
328
|
+
left: 0;
|
|
329
|
+
right: 0;
|
|
330
|
+
z-index: 40;
|
|
331
|
+
flex-direction: column;
|
|
332
|
+
max-height: 240px;
|
|
333
|
+
overflow-y: auto;
|
|
334
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
335
|
+
border-radius: 8px;
|
|
336
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
337
|
+
box-shadow: 0 8px 24px var(--dsw-alias-bg-mask-2);
|
|
338
|
+
padding: 4px;
|
|
339
|
+
opacity: 0;
|
|
340
|
+
transform: translateY(-4px);
|
|
341
|
+
transition: opacity 0.1s ease, transform 0.1s ease;
|
|
342
|
+
display: flex;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.selectPopupOpen {
|
|
346
|
+
opacity: 1;
|
|
347
|
+
transform: none;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.selectPopupClose {
|
|
351
|
+
opacity: 0;
|
|
352
|
+
transform: translateY(-4px);
|
|
353
|
+
pointer-events: none;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.selectOption {
|
|
357
|
+
border-radius: 6px;
|
|
358
|
+
padding: 6px 10px;
|
|
359
|
+
font-size: 13px;
|
|
360
|
+
line-height: 1.5;
|
|
361
|
+
color: var(--dsw-alias-label-primary);
|
|
362
|
+
cursor: pointer;
|
|
363
|
+
white-space: nowrap;
|
|
364
|
+
overflow: hidden;
|
|
365
|
+
text-overflow: ellipsis;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.selectOption:hover,
|
|
369
|
+
.selectOptionActive {
|
|
370
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.selectOptionSelected {
|
|
374
|
+
color: var(--dsw-alias-brand-primary);
|
|
375
|
+
font-weight: 500;
|
|
376
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);
|
|
377
|
+
}
|
|
378
|
+
|
|
293
379
|
.invalid {
|
|
294
380
|
color: var(--dsw-alias-label-error);
|
|
295
381
|
margin: 0;
|
|
@@ -310,7 +396,10 @@
|
|
|
310
396
|
.chevron,
|
|
311
397
|
.chevronOpen,
|
|
312
398
|
.discard,
|
|
313
|
-
.save
|
|
399
|
+
.save,
|
|
400
|
+
.selectChevron,
|
|
401
|
+
.selectChevronOpen,
|
|
402
|
+
.selectPopup {
|
|
314
403
|
transition: none;
|
|
315
404
|
}
|
|
316
405
|
}
|
package/src/gate.ts
CHANGED
|
@@ -89,8 +89,22 @@ export function makeGateListener(
|
|
|
89
89
|
if (!active) return false
|
|
90
90
|
const require = typeof requirePairingForLan === 'function' ? requirePairingForLan() : requirePairingForLan
|
|
91
91
|
if (!require) return next()
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return service
|
|
92
|
+
// The paired-device cookie is a full host-API credential: it passes this
|
|
93
|
+
// gate for the whole ApiProxy surface, not just the /m/api allowlist.
|
|
94
|
+
return isPairedDeviceRequest(service, request) ? next() : false
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether a request carries a live, non-revoked paired-device cookie for
|
|
100
|
+
* this service. Sibling host routes outside /api (aionui-panel, etc.) use
|
|
101
|
+
* the same check via the remoteWebUiPairing service.
|
|
102
|
+
* @param service - the pairing service that owns the device table.
|
|
103
|
+
* @param request - the incoming HTTP request.
|
|
104
|
+
* @returns true when the cookie names a live session (and lastSeenAt was refreshed).
|
|
105
|
+
*/
|
|
106
|
+
export function isPairedDeviceRequest(service: PairingService, request: IncomingMessage): boolean {
|
|
107
|
+
const deviceId = readCookie(request.headers.cookie, service.config.cookieName)
|
|
108
|
+
if (deviceId === undefined) return false
|
|
109
|
+
return service.touchDevice(deviceId)
|
|
110
|
+
}
|