@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.
Files changed (126) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +56 -26
  3. package/README.zh.md +20 -15
  4. package/assets/mobile-icon-192.png +0 -0
  5. package/assets/mobile-icon-512.png +0 -0
  6. package/assets/mobile-service-worker.js +64 -0
  7. package/lib/client.js +272 -47
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +316 -60
  10. package/lib/mobile.js +487 -56
  11. package/lib/mobile.js.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.d.ts +28 -0
  13. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PluginSettingsCard.js +147 -6
  15. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  16. package/lib/types/client/RemoteEntry.js +13 -2
  17. package/lib/types/client/RemoteSettingsCard.d.ts +2 -2
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +1 -1
  20. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  21. package/lib/types/client/UpdateEntry.js +36 -3
  22. package/lib/types/client/UpdatePanel.d.ts.map +1 -1
  23. package/lib/types/client/UpdatePanel.js +2 -2
  24. package/lib/types/client/deep-link.js +1 -1
  25. package/lib/types/client/locales.d.ts +4 -0
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +4 -0
  28. package/lib/types/gate.d.ts +9 -0
  29. package/lib/types/gate.d.ts.map +1 -1
  30. package/lib/types/gate.js +17 -4
  31. package/lib/types/index.d.ts +25 -0
  32. package/lib/types/index.d.ts.map +1 -1
  33. package/lib/types/index.js +30 -13
  34. package/lib/types/mobile/PairRequiredView.d.ts +7 -0
  35. package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
  36. package/lib/types/mobile/PairRequiredView.js +28 -0
  37. package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
  38. package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
  39. package/lib/types/mobile/PairRequiredView.test.js +24 -0
  40. package/lib/types/mobile/api.d.ts +10 -0
  41. package/lib/types/mobile/api.d.ts.map +1 -1
  42. package/lib/types/mobile/api.js +4 -0
  43. package/lib/types/mobile/index.js +14 -1
  44. package/lib/types/mobile/messages.d.ts +24 -0
  45. package/lib/types/mobile/messages.d.ts.map +1 -1
  46. package/lib/types/mobile/messages.js +60 -23
  47. package/lib/types/mobile/messages.test.js +49 -1
  48. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  49. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  50. package/lib/types/mobile/mobile-styles.js +86 -0
  51. package/lib/types/mobile/pairing.d.ts +31 -0
  52. package/lib/types/mobile/pairing.d.ts.map +1 -0
  53. package/lib/types/mobile/pairing.js +62 -0
  54. package/lib/types/mobile/views/App.d.ts +12 -5
  55. package/lib/types/mobile/views/App.d.ts.map +1 -1
  56. package/lib/types/mobile/views/App.js +34 -7
  57. package/lib/types/mobile/views/App.test.d.ts +2 -0
  58. package/lib/types/mobile/views/App.test.d.ts.map +1 -0
  59. package/lib/types/mobile/views/App.test.js +52 -0
  60. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  61. package/lib/types/mobile/views/ChatView.js +34 -11
  62. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  63. package/lib/types/mobile/views/SessionListView.js +30 -4
  64. package/lib/types/mobile/views/SessionListView.test.js +58 -1
  65. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  66. package/lib/types/mobile/views/WorkspaceView.js +4 -2
  67. package/lib/types/mobile-api.d.ts.map +1 -1
  68. package/lib/types/mobile-api.js +10 -1
  69. package/lib/types/mobile-pwa.d.ts +6 -0
  70. package/lib/types/mobile-pwa.d.ts.map +1 -0
  71. package/lib/types/mobile-pwa.js +17 -0
  72. package/lib/types/mobile-routes.d.ts +5 -8
  73. package/lib/types/mobile-routes.d.ts.map +1 -1
  74. package/lib/types/mobile-routes.js +115 -30
  75. package/lib/types/pairing-access.d.ts +28 -0
  76. package/lib/types/pairing-access.d.ts.map +1 -0
  77. package/lib/types/pairing-access.js +26 -0
  78. package/lib/types/pairing.d.ts +27 -0
  79. package/lib/types/pairing.d.ts.map +1 -1
  80. package/lib/types/pairing.js +68 -0
  81. package/lib/types/pairing.persist.test.d.ts +2 -0
  82. package/lib/types/pairing.persist.test.d.ts.map +1 -0
  83. package/lib/types/pairing.persist.test.js +128 -0
  84. package/lib/types/routes.d.ts.map +1 -1
  85. package/lib/types/routes.js +23 -3
  86. package/lib/types/tunnel.d.ts +1 -0
  87. package/lib/types/tunnel.d.ts.map +1 -1
  88. package/lib/types/tunnel.js +7 -2
  89. package/lib/types/update.d.ts +4 -4
  90. package/lib/types/update.d.ts.map +1 -1
  91. package/lib/types/update.js +35 -12
  92. package/package.json +12 -12
  93. package/src/client/PluginSettingsCard.tsx +229 -18
  94. package/src/client/RemoteEntry.tsx +12 -2
  95. package/src/client/RemoteSettingsCard.tsx +3 -2
  96. package/src/client/UpdateEntry.tsx +36 -4
  97. package/src/client/UpdatePanel.tsx +2 -0
  98. package/src/client/deep-link.ts +1 -1
  99. package/src/client/locales.ts +4 -0
  100. package/src/client/remote.module.css +26 -0
  101. package/src/client/settings-card.module.css +90 -1
  102. package/src/gate.ts +17 -3
  103. package/src/index.ts +46 -15
  104. package/src/mobile/PairRequiredView.test.tsx +28 -0
  105. package/src/mobile/PairRequiredView.tsx +58 -0
  106. package/src/mobile/api.ts +14 -1
  107. package/src/mobile/index.tsx +15 -1
  108. package/src/mobile/messages.test.ts +52 -1
  109. package/src/mobile/messages.ts +65 -22
  110. package/src/mobile/mobile-styles.ts +86 -0
  111. package/src/mobile/pairing.ts +77 -0
  112. package/src/mobile/views/App.test.tsx +62 -0
  113. package/src/mobile/views/App.tsx +53 -6
  114. package/src/mobile/views/ChatView.tsx +33 -10
  115. package/src/mobile/views/SessionListView.test.tsx +69 -1
  116. package/src/mobile/views/SessionListView.tsx +58 -5
  117. package/src/mobile/views/WorkspaceView.tsx +4 -2
  118. package/src/mobile-api.ts +9 -1
  119. package/src/mobile-pwa.ts +19 -0
  120. package/src/mobile-routes.ts +123 -30
  121. package/src/pairing-access.ts +36 -0
  122. package/src/pairing.persist.test.ts +145 -0
  123. package/src/pairing.ts +70 -1
  124. package/src/routes.ts +23 -3
  125. package/src/tunnel.ts +7 -2
  126. 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
- <select
491
+ <SelectField
278
492
  id={props.id}
279
- className={css.select}
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
- onChange={(event) => { props.onEdit(event.target.value) }}
283
- >
284
- <option value="">{props.inheritLabel}</option>
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
- <select
535
+ <SelectField
321
536
  id={props.id}
322
- className={css.select}
537
+ options={[{ value: '', label: props.inheritLabel }, ...props.choices]}
323
538
  value={props.text}
324
539
  disabled={props.disabled}
325
- onChange={(event) => { props.onEdit(event.target.value) }}
326
- >
327
- <option value="">{props.inheritLabel}</option>
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 button matching the settings rail/row geometry. */
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 ? undefined : 'rail'}
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 `settings.plugin.item` slot the plugin-configuration
4
- * section renders, bound to the `remote-web-ui` settings namespace.
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
- // Unmount safety: an in-flight update must not land on a dead component.
82
- useEffect(() => () => { runToken.current++ }, [])
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
- aria-label={t("update.label")}
91
- title={t("update.label")}
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
  }
@@ -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()
@@ -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
- const deviceId = readCookie(request.headers.cookie, service.config.cookieName)
93
- if (deviceId === undefined) return false
94
- return service.touchDevice(deviceId) ? next() : false
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
+ }