dsh-zen-remote 2.0.3 → 2.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 (119) hide show
  1. package/AGENTS.md +15 -1
  2. package/README.md +11 -5
  3. package/README.zh-CN.md +11 -5
  4. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +15 -0
  5. package/docs/adr/0002-relay-emulates-websocket.md +8 -0
  6. package/docs/interface.md +3 -1
  7. package/docs/plugin-bridge.md +140 -0
  8. package/docs/remote-access.en.md +57 -0
  9. package/docs/remote-access.md +57 -0
  10. package/lib/activity.js +25 -0
  11. package/lib/activity.js.map +1 -1
  12. package/lib/adapters/dsh-better-sidebar/index.js +144 -0
  13. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
  14. package/lib/adapters/dsh-better-sidebar/methods.js +221 -0
  15. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
  16. package/lib/adapters/index.js +17 -0
  17. package/lib/adapters/index.js.map +1 -0
  18. package/lib/adapters/types.js +17 -0
  19. package/lib/adapters/types.js.map +1 -0
  20. package/lib/admin-routes.js +51 -0
  21. package/lib/admin-routes.js.map +1 -1
  22. package/lib/client-routes.js +18 -1
  23. package/lib/client-routes.js.map +1 -1
  24. package/lib/client.js +449 -45
  25. package/lib/config.js +27 -0
  26. package/lib/config.js.map +1 -1
  27. package/lib/http.js +10 -0
  28. package/lib/http.js.map +1 -1
  29. package/lib/index.js +273 -1
  30. package/lib/index.js.map +1 -1
  31. package/lib/plugin-adapter-core.js +385 -0
  32. package/lib/plugin-adapter-core.js.map +1 -0
  33. package/lib/plugin-bridge.js +318 -0
  34. package/lib/plugin-bridge.js.map +1 -0
  35. package/lib/plugin-loopback.js +207 -0
  36. package/lib/plugin-loopback.js.map +1 -0
  37. package/lib/plugin-route-intercept.js +1103 -0
  38. package/lib/plugin-route-intercept.js.map +1 -0
  39. package/lib/plugin-tunnel.js +416 -0
  40. package/lib/plugin-tunnel.js.map +1 -0
  41. package/lib/relay-client.js +319 -2
  42. package/lib/relay-client.js.map +1 -1
  43. package/lib/relay-server.js +633 -0
  44. package/lib/relay-server.js.map +1 -1
  45. package/lib/types/activity.d.ts +22 -0
  46. package/lib/types/activity.d.ts.map +1 -1
  47. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
  48. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
  49. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +58 -0
  50. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
  51. package/lib/types/adapters/index.d.ts +17 -0
  52. package/lib/types/adapters/index.d.ts.map +1 -0
  53. package/lib/types/adapters/types.d.ts +42 -0
  54. package/lib/types/adapters/types.d.ts.map +1 -0
  55. package/lib/types/admin-routes.d.ts +16 -0
  56. package/lib/types/admin-routes.d.ts.map +1 -1
  57. package/lib/types/client/effects/remote-session.d.ts +0 -22
  58. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  59. package/lib/types/client/locales.d.ts +16 -0
  60. package/lib/types/client/locales.d.ts.map +1 -1
  61. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  62. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  63. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  64. package/lib/types/client-data/plugin-bridge.d.ts +79 -0
  65. package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
  66. package/lib/types/client-data/remote-status.d.ts +20 -0
  67. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  68. package/lib/types/client-data/settings-form.d.ts +37 -1
  69. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  70. package/lib/types/client-routes.d.ts +13 -0
  71. package/lib/types/client-routes.d.ts.map +1 -1
  72. package/lib/types/config.d.ts +8 -0
  73. package/lib/types/config.d.ts.map +1 -1
  74. package/lib/types/http.d.ts +7 -0
  75. package/lib/types/http.d.ts.map +1 -1
  76. package/lib/types/index.d.ts +4 -0
  77. package/lib/types/index.d.ts.map +1 -1
  78. package/lib/types/plugin-adapter-core.d.ts +263 -0
  79. package/lib/types/plugin-adapter-core.d.ts.map +1 -0
  80. package/lib/types/plugin-bridge.d.ts +187 -0
  81. package/lib/types/plugin-bridge.d.ts.map +1 -0
  82. package/lib/types/plugin-loopback.d.ts +104 -0
  83. package/lib/types/plugin-loopback.d.ts.map +1 -0
  84. package/lib/types/plugin-route-intercept.d.ts +114 -0
  85. package/lib/types/plugin-route-intercept.d.ts.map +1 -0
  86. package/lib/types/plugin-tunnel.d.ts +96 -0
  87. package/lib/types/plugin-tunnel.d.ts.map +1 -0
  88. package/lib/types/relay-client.d.ts +76 -0
  89. package/lib/types/relay-client.d.ts.map +1 -1
  90. package/lib/types/relay-server.d.ts +32 -1
  91. package/lib/types/relay-server.d.ts.map +1 -1
  92. package/llms.txt +1 -0
  93. package/package.json +2 -2
  94. package/scripts/check-plugin-bridge-ui.mjs +168 -0
  95. package/scripts/check-settings-form.mjs +3 -1
  96. package/src/activity.ts +32 -0
  97. package/src/adapters/dsh-better-sidebar/index.ts +144 -0
  98. package/src/adapters/dsh-better-sidebar/methods.ts +244 -0
  99. package/src/adapters/index.ts +19 -0
  100. package/src/adapters/types.ts +44 -0
  101. package/src/admin-routes.ts +64 -0
  102. package/src/client/effects/remote-session.ts +82 -10
  103. package/src/client/locales.ts +36 -0
  104. package/src/client/settings/SettingsSection.tsx +107 -3
  105. package/src/client/styles/remote-session.css.ts +70 -31
  106. package/src/client-data/plugin-bridge.ts +150 -0
  107. package/src/client-data/remote-status.ts +30 -0
  108. package/src/client-data/settings-form.ts +78 -0
  109. package/src/client-routes.ts +29 -1
  110. package/src/config.ts +32 -0
  111. package/src/http.ts +12 -0
  112. package/src/index.ts +279 -1
  113. package/src/plugin-adapter-core.ts +530 -0
  114. package/src/plugin-bridge.ts +435 -0
  115. package/src/plugin-loopback.ts +299 -0
  116. package/src/plugin-route-intercept.ts +1193 -0
  117. package/src/plugin-tunnel.ts +470 -0
  118. package/src/relay-client.ts +394 -2
  119. package/src/relay-server.ts +656 -1
@@ -2,6 +2,8 @@ import type { ClientContext, MobileSessionRow } from '../compat/types.ts'
2
2
  import { mainSessionIdOf } from '../compat/types.ts'
3
3
  import { isVirtual } from '../../virtual-id.ts'
4
4
  import { REMOTE_SESSION_CSS } from '../styles/remote-session.css.ts'
5
+ import { BRIDGE_STAMP_ATTRIBUTE, BRIDGE_STAMP_PLUGIN, getRemoteStatusStore, shouldStampBridgeAttribute, subscribeIfVirtual } from '../../client-data/remote-status.ts'
6
+ import { en, zh } from '../locales.ts'
5
7
 
6
8
  /**
7
9
  * The remote-session marker (T41b): the CURRENT main-view session id, read
@@ -25,6 +27,29 @@ import { REMOTE_SESSION_CSS } from '../styles/remote-session.css.ts'
25
27
  * attribute must track the current session regardless of which panel is
26
28
  * mounted, and an attribute + stylesheet survives every re-render untouched.
27
29
  */
30
+
31
+ /** The CURRENT main-view session id, the publishMain way (see the module
32
+ * comment). Shared by the session marker and the bridge stamp — both key on
33
+ * the same "which session is on screen" answer. */
34
+ function currentMainSessionIdOf(ctx: ClientContext): string | undefined {
35
+ const { byId } = ctx.sessions.list.getSnapshot()
36
+ const uiSession = ctx.get('uiSession') as { current?: { value?: { key?: unknown } } } | undefined
37
+ const current = uiSession?.current?.value?.key
38
+ const rows = byId as Readonly<Record<string, MobileSessionRow>>
39
+ return typeof current === 'string' && (rows[current]?.retainedBy?.mainView ?? 0) > 0
40
+ ? current
41
+ : mainSessionIdOf(byId)
42
+ }
43
+
44
+ /** The unbridged note copy in the BROWSER's language (the plugin's own
45
+ * namespace carries both dictionaries; the framework `t` seat is not
46
+ * reachable from a non-React effect, so the navigator's language picks —
47
+ * `zh*` reads Chinese, everything else English). */
48
+ function bridgeNoteOf(language: string | undefined): string {
49
+ const key = 'remoteBridgeUnbridgedNote' as const
50
+ return typeof language === 'string' && language.startsWith('zh') ? zh[key] : en[key]
51
+ }
52
+
28
53
  export function installRemoteSessionGuard(ctx: ClientContext): void {
29
54
  ctx.effect(() => {
30
55
  const tag = document.createElement('style')
@@ -39,16 +64,7 @@ export function installRemoteSessionGuard(ctx: ClientContext): void {
39
64
 
40
65
  ctx.effect(() => {
41
66
  const apply = (): void => {
42
- const { byId } = ctx.sessions.list.getSnapshot()
43
- // publishMain's tie-break (see the module comment): prefer the still-
44
- // retained current main binding, else the first mainView row.
45
- const uiSession = ctx.get('uiSession') as { current?: { value?: { key?: unknown }, subscribe?: (listener: () => void) => () => void } } | undefined
46
- const current = uiSession?.current?.value?.key
47
- const rows = byId as Readonly<Record<string, MobileSessionRow>>
48
- const sessionId =
49
- typeof current === 'string' && (rows[current]?.retainedBy?.mainView ?? 0) > 0
50
- ? current
51
- : mainSessionIdOf(byId)
67
+ const sessionId = currentMainSessionIdOf(ctx)
52
68
  const root = document.documentElement
53
69
  if (sessionId !== undefined && isVirtual(sessionId)) root.setAttribute('data-zr-remote-session', '1')
54
70
  else root.removeAttribute('data-zr-remote-session')
@@ -65,4 +81,60 @@ export function installRemoteSessionGuard(ctx: ClientContext): void {
65
81
  document.documentElement.removeAttribute('data-zr-remote-session')
66
82
  }
67
83
  }, 'dsh-zen-remote: remote-session attribute')
84
+
85
+ // The bridge stamp (PB-14): while a REMOTE session is on screen, the
86
+ // remote-status poll's `bridgedPlugins` decides whether better-sidebar is
87
+ // bridged RIGHT NOW — bridged, the T53 hiding rules lift (the panels read
88
+ // the server's data through the adapter) and the html carries
89
+ // `data-zr-bridge-dsh-better-sidebar="1"`; not bridged, the rules stay
90
+ // and the html carries the unbridged note copy as a CSS variable the
91
+ // stylesheet spends on the empty tab body. The subscription rides
92
+ // subscribeIfVirtual — a LOCAL session (or a host page) never polls, and
93
+ // the attribute comes off; better-sidebar not installed or the server not
94
+ // paired simply never appear in `bridgedPlugins`, so the stamp never
95
+ // fires from this side alone.
96
+ ctx.effect(() => {
97
+ const root = document.documentElement
98
+ const store = getRemoteStatusStore()
99
+ let offStore: (() => void) | undefined
100
+ const clear = (): void => {
101
+ root.removeAttribute(BRIDGE_STAMP_ATTRIBUTE)
102
+ root.style.removeProperty('--zr-bridge-note')
103
+ }
104
+ const applyBridge = (): void => {
105
+ const view = store.getSnapshot().view
106
+ if (shouldStampBridgeAttribute(view?.bridgedPlugins, BRIDGE_STAMP_PLUGIN)) {
107
+ root.setAttribute(BRIDGE_STAMP_ATTRIBUTE, '1')
108
+ root.style.removeProperty('--zr-bridge-note')
109
+ } else {
110
+ root.removeAttribute(BRIDGE_STAMP_ATTRIBUTE)
111
+ // JSON.stringify's quotes are a legal CSS string — the stylesheet's
112
+ // ::before spends it as `content: var(--zr-bridge-note)`.
113
+ root.style.setProperty('--zr-bridge-note', JSON.stringify(bridgeNoteOf(navigator.language)))
114
+ }
115
+ }
116
+ const retune = (): void => {
117
+ const sessionId = currentMainSessionIdOf(ctx)
118
+ const virtualNow = sessionId !== undefined && isVirtual(sessionId)
119
+ if (virtualNow && offStore === undefined && typeof sessionId === 'string') {
120
+ offStore = subscribeIfVirtual(store, sessionId, applyBridge)
121
+ applyBridge()
122
+ return
123
+ }
124
+ if (!virtualNow && offStore !== undefined) {
125
+ offStore()
126
+ offStore = undefined
127
+ clear()
128
+ }
129
+ }
130
+ retune()
131
+ const offs: Array<() => void> = [ctx.sessions.list.subscribe(retune)]
132
+ const current = (ctx.get('uiSession') as { current?: { subscribe?: (listener: () => void) => () => void } } | undefined)?.current
133
+ if (typeof current?.subscribe === 'function') offs.push(current.subscribe(retune))
134
+ return () => {
135
+ for (const off of offs) off()
136
+ offStore?.()
137
+ clear()
138
+ }
139
+ }, 'dsh-zen-remote: bridge stamp')
68
140
  }
@@ -253,6 +253,22 @@ export const zh = {
253
253
  'settings.client.diagRecentFailures': '最近的远程调用失败',
254
254
  'settings.client.diagCompatGroups': '指纹有差异的组:{groups}',
255
255
  'settings.client.diagCompatCalls': '最近的不兼容调用',
256
+ // --- PB-5 插件桥:主服务端同意小节 + 子客户端诊断小节 ---
257
+ 'settings.bridge.title': '插件桥',
258
+ 'settings.bridge.empty': '暂无插件登记插件桥',
259
+ 'settings.bridge.loading': '正在读取插件桥登记…',
260
+ 'settings.bridge.loadFailed': '插件桥列表读取失败(旧版服务端或经网关打开的页面)',
261
+ 'settings.bridge.sessionOnly': '随会话共享转发',
262
+ 'settings.bridge.consentToggle': '允许转发整机数据',
263
+ 'settings.bridge.consentHint': '打开后,已配对的桌面应用端可在远程会话里读取该插件的整机数据。',
264
+ 'settings.bridge.stateBridged': '已桥接',
265
+ 'settings.bridge.stateNotOnServer': '服务端未安装',
266
+ 'settings.bridge.stateVersionMismatch': '版本不同(服务端 {version})',
267
+ 'settings.bridge.stateDeclarationMismatch': '声明不一致',
268
+ 'settings.bridge.stateConsentOff': '服务端主人未允许整机数据',
269
+ 'settings.bridge.stateOffline': '中继离线',
270
+ 'settings.bridge.stateServerTooOld': '服务端 zen-remote 版本过旧',
271
+ 'settings.bridge.interceptOff': '本机改道未启用:{reason}',
256
272
  // --- T34 子客户端远程状态:标题行图标、横幅、关闭远程 ---
257
273
  'remoteStatusOnline': '远程会话 · 已连接',
258
274
  'remoteStatusOffline': '服务端离线,正在重连',
@@ -269,6 +285,9 @@ export const zh = {
269
285
  'remoteStatusOfflineClick': '服务端离线,无法操作',
270
286
  'remoteStatusUnshareFail': '关闭远程失败,请稍后再试',
271
287
  'remoteStatusUnshareFailCode': '关闭远程失败({code}),请稍后再试',
288
+ // --- PB-14: the unbridged better-sidebar note (a CSS variable the
289
+ // remote-session stylesheet spends as the empty tab's ::before copy) ---
290
+ 'remoteBridgeUnbridgedNote': 'better-sidebar 未在远程会话桥接(服务端未装同版本或未允许),文件与改动请以会话内消息为准',
272
291
  // 远程分组标题的后缀(后台 merge-streams 拿不到界面语言,固定用中文);
273
292
  // 这里登记英文对照,供界面侧需要复述同样状态时使用。
274
293
  'remoteGroupOffline': '(离线)',
@@ -524,6 +543,22 @@ export const en: Record<MobileNavKey, string> = {
524
543
  'settings.client.diagRecentFailures': 'Recent remote call failures',
525
544
  'settings.client.diagCompatGroups': 'Groups with fingerprint differences: {groups}',
526
545
  'settings.client.diagCompatCalls': 'Recent incompatible calls',
546
+ // --- PB-5 plugin bridge: host consent section + sub-client diagnostics ---
547
+ 'settings.bridge.title': 'Plugin bridge',
548
+ 'settings.bridge.empty': 'No plugin has registered a bridge yet',
549
+ 'settings.bridge.loading': 'Loading the bridge registrations…',
550
+ 'settings.bridge.loadFailed': 'The bridge listing failed to load (an older server, or the page opened through the gateway)',
551
+ 'settings.bridge.sessionOnly': 'Forwards with shared sessions',
552
+ 'settings.bridge.consentToggle': 'Allow forwarding whole-machine data',
553
+ 'settings.bridge.consentHint': 'When on, paired desktop apps can read this plugin\u2019s whole-machine data in remote sessions.',
554
+ 'settings.bridge.stateBridged': 'Bridged',
555
+ 'settings.bridge.stateNotOnServer': 'Not installed on the server',
556
+ 'settings.bridge.stateVersionMismatch': 'Version differs (server {version})',
557
+ 'settings.bridge.stateDeclarationMismatch': 'Declaration mismatch',
558
+ 'settings.bridge.stateConsentOff': 'Server owner has not allowed whole-machine data',
559
+ 'settings.bridge.stateOffline': 'Relay offline',
560
+ 'settings.bridge.stateServerTooOld': 'Server zen-remote is too old',
561
+ 'settings.bridge.interceptOff': 'Local rerouting not enabled: {reason}',
527
562
  // --- T34 remote-status icon, composer banner, unshare ---
528
563
  'remoteStatusOnline': 'Remote session · connected',
529
564
  'remoteStatusOffline': 'Server offline — reconnecting',
@@ -540,6 +575,7 @@ export const en: Record<MobileNavKey, string> = {
540
575
  'remoteStatusOfflineClick': 'The server is offline — nothing to do',
541
576
  'remoteStatusUnshareFail': 'Closing the remote failed — try again later',
542
577
  'remoteStatusUnshareFailCode': 'Closing the remote failed ({code}) — try again later',
578
+ 'remoteBridgeUnbridgedNote': 'better-sidebar is not bridged for this remote session (the server lacks the same version or has not allowed it) — treat files and changes as session messages only',
543
579
  // Remote group title suffixes (the background merger cannot know the UI
544
580
  // language and always uses the Chinese forms); the English equivalents
545
581
  // live here for UI surfaces that restate the same status.
@@ -35,11 +35,12 @@
35
35
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
36
36
  import type { ReactNode } from 'react'
37
37
  import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
38
- import { Button, SettingsForm, SettingsValueField } from '@deepseek-ai/dsh-client-ui-primitives'
38
+ import { Button, SettingsForm, SettingsValueField, Switch } from '@deepseek-ai/dsh-client-ui-primitives'
39
39
  import type { SettingsFormLabels } from '@deepseek-ai/dsh-client-ui-primitives'
40
40
  import {
41
41
  ADMIN_ACTION_ROUTE,
42
42
  ADMIN_PAIR_ROUTE,
43
+ ADMIN_PLUGIN_BRIDGE_ROUTE,
43
44
  ADMIN_PUSH_TEST_ROUTE,
44
45
  ADMIN_STATUS_ROUTE,
45
46
  CLIENT_CLAIM_ROUTE,
@@ -55,6 +56,8 @@ import {
55
56
  normalizePairingCode,
56
57
  shouldFollowServerDeviceName,
57
58
  } from '../../client-data/settings-form.ts'
59
+ import { bridgeSectionState, togglePluginConsent } from '../../client-data/plugin-bridge.ts'
60
+ import type { PluginBridgeStatusView } from '../../client-data/plugin-bridge.ts'
58
61
  import type {
59
62
  AdminStatusBody,
60
63
  ClaimRouteBody,
@@ -374,6 +377,40 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
374
377
  ? deriveSettingsView(load.body, { now, rowUser: config.rowUser() })
375
378
  : undefined
376
379
  const clientRole = config.savedRoleIsClient()
380
+
381
+ // The plugin bridge's registered-plugin listing (PB-5), host page only:
382
+ // loaded once per page life and refreshed whenever a save lands (the
383
+ // consent staged by the toggles below rides that very save). Starts in
384
+ // the loading state — the section renders the loading copy until the
385
+ // first answer lands (or the load fails, which is its own state, never
386
+ // the empty one); a composition without the route stays loading.
387
+ // PB-9-fix: the listing is a THREE-state read — loading, failed (an old
388
+ // build, or the page opened through the gateway where the listing is
389
+ // refused), or an answer that carries zero rows. Only the third means
390
+ // "nothing registered"; the first two must not masquerade as it.
391
+ const [bridgeLoad, setBridgeLoad] = useState<{ state: 'loading' | 'error' | 'ready', rows: { plugin: string, version: string, hasServerScope: boolean, consent: boolean }[] }>({ state: 'loading', rows: [] })
392
+ const loadBridge = useCallback(() => {
393
+ setBridgeLoad((prev) => ({ state: 'loading', rows: prev.rows }))
394
+ fetch(ADMIN_PLUGIN_BRIDGE_ROUTE)
395
+ .then((res) => {
396
+ if (!res.ok) return Promise.reject(new Error(String(res.status)))
397
+ return res.json() as Promise<{ value?: { plugins?: unknown } }>
398
+ })
399
+ .then((body) => {
400
+ const plugins = body?.value?.plugins
401
+ if (!Array.isArray(plugins)) return
402
+ setBridgeLoad({
403
+ state: 'ready',
404
+ rows: plugins.filter((row): row is { plugin: string, version: string, hasServerScope: boolean, consent: boolean } =>
405
+ row !== null && typeof row === 'object' && typeof (row as { plugin?: unknown }).plugin === 'string'),
406
+ })
407
+ })
408
+ .catch(() => { setBridgeLoad((prev) => ({ state: 'error', rows: prev.rows })) })
409
+ }, [])
410
+ useEffect(() => {
411
+ if (!clientRole) loadBridge()
412
+ }, [clientRole, loadBridge])
413
+ const bridgeState = bridgeSectionState(bridgeLoad.state, bridgeLoad.rows)
377
414
  const statusPoll = config.statusPoll()
378
415
 
379
416
  useEffect(() => {
@@ -817,6 +854,9 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
817
854
  else setClientLoad({ state: 'loading' })
818
855
  const refresh = config.savedRoleIsClient() ? loadClientStatus : loadStatus
819
856
  refresh()
857
+ // The plugin bridge listing re-reads too (PB-5): the consent toggles
858
+ // stage through the very save that just landed.
859
+ if (!config.savedRoleIsClient()) loadBridge()
820
860
  // Immediate pull + 1.5s covers a hot-only save. A save that reloads the
821
861
  // plugin row (T17) restarts the row and the gateway child with it — the
822
862
  // reload and the gateway's rebinding of its port both take longer than
@@ -901,6 +941,49 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
901
941
  <h3 className="zr-settings-card-title">{t('settings.shareTitle')}</h3>
902
942
  {text(form.idleHours, 'idleHours', t('settings.fieldIdleHours'), t('settings.fieldIdleHoursHint'), t('settings.invalidHours'), true)}
903
943
  {bool(form.autoShareNewSessions, 'autoShareNewSessions', t('settings.fieldAutoShare'), t('settings.fieldAutoShareHint'))}
944
+
945
+ {/* 插件桥 (PB-5): the consent toggles stage the NEXT consent map
946
+ over the SAME form — one save button, one revision fence —
947
+ reading the CURRENT map off the staged (or displayed) draft
948
+ so consecutive toggles compose. */}
949
+ <h3 className="zr-settings-card-title">{t('settings.bridge.title')}</h3>
950
+ {bridgeState === 'loading' && <p className="zr-settings-hint">{t('settings.bridge.loading')}</p>}
951
+ {bridgeState === 'error' && <p className="zr-settings-hint" data-invalid="true">{t('settings.bridge.loadFailed')}</p>}
952
+ {bridgeState === 'empty' && <p className="zr-settings-hint">{t('settings.bridge.empty')}</p>}
953
+ {bridgeLoad.rows.map((row) => {
954
+ let current: Record<string, boolean> = {}
955
+ try {
956
+ const parsed: unknown = JSON.parse(form.pluginBridgeConsent.text)
957
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
958
+ current = parsed as Record<string, boolean>
959
+ }
960
+ } catch { /* a malformed draft reads as nothing consented */ }
961
+ return (
962
+ <div className="zr-settings-field" key={row.plugin}>
963
+ <div className="zr-settings-head">
964
+ <span className="zr-settings-head-title">{row.plugin}</span>
965
+ <span className="zr-settings-badge">{row.version}</span>
966
+ </div>
967
+ {row.hasServerScope ? (
968
+ <>
969
+ <div className="zr-settings-head">
970
+ <label>{t('settings.bridge.consentToggle')}</label>
971
+ <Switch
972
+ checked={current[row.plugin] === true}
973
+ onChange={(checked) => {
974
+ config.stage('pluginBridgeConsent', JSON.stringify(togglePluginConsent(current, row.plugin, checked)))
975
+ }}
976
+ label={t('settings.bridge.consentToggle')}
977
+ />
978
+ </div>
979
+ <p className="zr-settings-hint">{t('settings.bridge.consentHint')}</p>
980
+ </>
981
+ ) : (
982
+ <p className="zr-settings-hint">{t('settings.bridge.sessionOnly')}</p>
983
+ )}
984
+ </div>
985
+ )
986
+ })}
904
987
  </>
905
988
  )}
906
989
  </SettingsForm>
@@ -1073,8 +1156,9 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
1073
1156
  client/status body. The interceptor (T23b) and compat (T42)
1074
1157
  groups render only when the response carried their fields —
1075
1158
  earlier servers answer neither, and the section itself only
1076
- exists when there is at least one line to show. */}
1077
- {clientView !== undefined && (clientView.lastError !== '' || clientView.intercept !== undefined || clientView.compat !== undefined) && (
1159
+ exists when there is at least one line to show. PB-5 adds the
1160
+ bridge rows and the rerouting note on the same presence gate. */}
1161
+ {clientView !== undefined && (clientView.lastError !== '' || clientView.intercept !== undefined || clientView.compat !== undefined || (clientView.pluginBridge?.length ?? 0) > 0 || clientView.pluginRouteIntercept !== undefined) && (
1078
1162
  <>
1079
1163
  <h3 className="zr-settings-card-title">{t('settings.client.diagTitle')}</h3>
1080
1164
  {clientView.lastError !== '' && (
@@ -1132,6 +1216,26 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
1132
1216
  )}
1133
1217
  </>
1134
1218
  )}
1219
+ {(clientView.pluginBridge?.length ?? 0) > 0 && clientView.pluginBridge !== undefined && (
1220
+ <div className="zr-settings-field">
1221
+ <div className="zr-settings-head">
1222
+ <label>{t('settings.bridge.title')}</label>
1223
+ </div>
1224
+ {clientView.pluginBridge.map((row: PluginBridgeStatusView) => (
1225
+ <p key={row.plugin} className="zr-settings-hint">
1226
+ {row.plugin} · {row.localVersion !== '' ? `v${row.localVersion}` : '—'} ·{' '}
1227
+ {row.stateKey === 'settings.bridge.stateVersionMismatch'
1228
+ ? t(row.stateKey, { version: row.serverVersion ?? '?' })
1229
+ : t(row.stateKey)}
1230
+ </p>
1231
+ ))}
1232
+ </div>
1233
+ )}
1234
+ {clientView.pluginRouteIntercept !== undefined && !clientView.pluginRouteIntercept.installed && (
1235
+ <p className="zr-settings-hint" data-invalid="true">
1236
+ {t('settings.bridge.interceptOff', { reason: clientView.pluginRouteIntercept.reasons.join('; ') || '—' })}
1237
+ </p>
1238
+ )}
1135
1239
  </>
1136
1240
  )}
1137
1241
  </div>
@@ -32,17 +32,23 @@ html[data-zr-remote-session="1"] [data-open-target] {
32
32
  display: none !important;
33
33
  }
34
34
 
35
- /* ---------- remote session: hide dsh-better-sidebar's file surfaces (T53) ----------
36
- dsh-better-sidebar 0.24.x is the second big "open the machine's files" UI.
37
- Every tab it registers (files/editor, git changes, subagent/tasks, side
38
- chat, the markdown/html/code file viewers) builds its requests from a
39
- {sessionId, cwd} scope and POSTs them to the plugin's own /sidebar/api/*
40
- routes on whichever machine answers — in a remote session that is the
41
- SUB-CLIENT's machine (client.js call() ~3111, fetchUpload ~3162,
42
- encodeHtmlUrl /sidebar/html/ ~3069-3414), so the tree would show same-named
43
- LOCAL files as if they were the session's and an edit would WRITE locally.
44
- Per the T53 decision the interfaces are hidden, not proxied: the relay
45
- carries no /sidebar routes and none are added.
35
+ /* ---------- remote session: hide dsh-better-sidebar's file surfaces (T53, two regimes since PB-14) ----------
36
+ dsh-better-sidebar 0.25.0 is the second big "open the machine's files" UI
37
+ AND, since PB-12/13, the first ADAPTER-BRIDGED one: at exactly 0.25.0 its
38
+ /sidebar/api routes, its two WebSockets and its side chat cross the byte
39
+ tunnel and read/write the SERVER's data, gated on the whole-machine-data
40
+ consent. The hiding below therefore applies ONLY while the plugin is NOT
41
+ bridged — the :not([data-zr-bridge-dsh-better-sidebar="1"]) stamp the
42
+ remote-status poll writes; see the PB-14 note above): bridged, the panels
43
+ are remote-aware and stay visible; unbridged (a different version, the
44
+ server without the adapter's consent), every tab it registers (files/
45
+ editor, git changes, subagent/tasks, side chat, the markdown/html/code
46
+ file viewers) builds its requests from a {sessionId, cwd} scope and POSTs
47
+ them to the plugin's own /sidebar/api/* routes on whichever machine
48
+ answers — in a remote session that is the SUB-CLIENT's machine (client.js
49
+ call() ~3111, fetchUpload ~3162, encodeHtmlUrl /sidebar/html/ ~3069-3414),
50
+ so the tree would show same-named LOCAL files as if they were the
51
+ session's and an edit would WRITE locally.
46
52
 
47
53
  Anchors, all the plugin's or the host's own rendered contracts (no hashed
48
54
  class names; the one suffix selector, _chipIcon, is unique among the
@@ -51,18 +57,21 @@ html[data-zr-remote-session="1"] [data-open-target] {
51
57
  @liustack/modsearch / @wxg-prc-cpg/browser-skill-dsh-plugin (modsearch /
52
58
  browser-skill) / dshmarket and every @deepseek-ai sidebar package for it):
53
59
 
54
- HONEST LIMITS (T53-fix): everything here is display:none ONLY. The plugin's
60
+ HONEST LIMITS (T53-fix, adjusted PB-14): unbridged, the plugin's
55
61
  components stay mounted and keep silently POSTing READ-ONLY /sidebar/api
56
62
  requests to the local machine (session.cwd, changes.ops, the tree reads a
57
63
  mounted tab refreshes with) — the data never leaves that machine, and no
58
- write can happen because every write control is inside the hidden surfaces.
59
- Known boundary: a dsh-resource://file/** address opened through
60
- sidebarRight.openResource in a remote session (file links in chat,
61
- deliverables, @-references, skills) is CLAIMED by the plugin's editor
62
- registration (client.js ~21377: patterns ["dsh-resource://file/**"]) before
63
- the host preview sees it — the right sidebar expands, the rules below hide
64
- the tab's body and chip, and an EMPTY panel remains. Root fix needs
65
- zen-remote's own remote file tree; separate discussion.
64
+ write can happen because every write control is inside the hidden
65
+ surfaces (the tab body hides its CHILDREN and spends the note copy as a
66
+ ::before, so an already-open tab reads WHY it is empty instead of
67
+ sitting blank). Known boundary: a dsh-resource://file/** address opened
68
+ through sidebarRight.openResource in a remote session (file links in
69
+ chat, deliverables, @-references, skills) is CLAIMED by the plugin's
70
+ editor registration (client.js ~21377: patterns ["dsh-resource://file/**"])
71
+ before the host preview sees it — the right sidebar expands, the rules
72
+ below hide the tab's body content and chip, and the note fills the panel.
73
+ Bridged (0.25.0 + consent), none of this applies: the tab reads the
74
+ server's data through the adapter and stays visible whole.
66
75
 
67
76
  - [data-dsh-bottom-toggle] — the header-utilities button that expands the
68
77
  bottom workbench (client.js BottomDockToggle ~20863; slot registration
@@ -135,31 +144,61 @@ html[data-zr-remote-session="1"] [data-open-target] {
135
144
  attribute and this rule hides it too (cosmetic; the page stays reachable
136
145
  through its shortcut command path and the plugin's takeover is the
137
146
  "files" implementation on every deployment this plugin pairs with
138
- better-sidebar anyway). */
139
- html[data-zr-remote-session="1"] [data-dsh-bottom-toggle] {
147
+ better-sidebar anyway).
148
+
149
+ /* PB-14: every T53 rule below carries
150
+ :not([data-zr-bridge-dsh-better-sidebar="1"]) — when the adapter
151
+ (PB-12) bridges the plugin, the panels read the SERVER's data through
152
+ the byte tunnel and MUST stay visible; the hiding applies only to the
153
+ unbridged combination, and the empty tab body then carries a note
154
+ instead of blankness. The stamp is written by
155
+ effects/remote-session.ts off the remote-status poll's bridgedPlugins
156
+ list (client-data/remote-status.ts shouldStampBridgeAttribute). */
157
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-dsh-bottom-toggle] {
140
158
  display: none !important;
141
159
  }
142
- html[data-zr-remote-session="1"] [data-dsh-panel-host] {
160
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-dsh-panel-host] {
143
161
  display: none !important;
144
162
  }
145
- html[data-zr-remote-session="1"] {
163
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) {
146
164
  --dsh-sidebar-height: 0px !important;
147
165
  }
148
- html[data-zr-remote-session="1"] [data-dsh-native-tab-host] {
166
+ /* The unbridged tab body: hide the plugin's own content and spend the
167
+ note copy as a ::before — an already-open tab reads WHY it is empty
168
+ (0.25.0 keeps tabs mounted, so a stale body would otherwise sit blank).
169
+ --zr-bridge-note is set on <html> by the stamp effect, in the
170
+ browser's language; unset (effect not running yet) the content falls
171
+ through and nothing renders. */
172
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-dsh-native-tab-host] > * {
173
+ display: none !important;
174
+ }
175
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-dsh-native-tab-host]::before {
176
+ content: var(--zr-bridge-note);
177
+ display: block;
178
+ padding: 24px 16px;
179
+ font-size: 13px;
180
+ line-height: 1.7;
181
+ color: var(--dsh-fg-muted, #6b7280);
182
+ word-break: break-word;
183
+ }
184
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-dockkit-tab]:has([class$="_chipIcon"]) {
149
185
  display: none !important;
150
186
  }
151
- html[data-zr-remote-session="1"] [data-dockkit-tab]:has([class$="_chipIcon"]) {
187
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-floating-window]:has([data-window-body]) {
152
188
  display: none !important;
153
189
  }
154
- html[data-zr-remote-session="1"] [data-floating-window]:has([data-window-body]) {
190
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-sidebar-right-guide-entry="git"],
191
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-sidebar-right-guide-entry="subagent"],
192
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-sidebar-right-guide-entry="sidechat"] {
155
193
  display: none !important;
156
194
  }
157
- html[data-zr-remote-session="1"] [data-sidebar-right-guide-entry="git"],
158
- html[data-zr-remote-session="1"] [data-sidebar-right-guide-entry="subagent"],
159
- html[data-zr-remote-session="1"] [data-sidebar-right-guide-entry="sidechat"] {
195
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-sidebar-right-guide-entry="files"]:not([aria-keyshortcuts]) {
160
196
  display: none !important;
161
197
  }
162
- html[data-zr-remote-session="1"] [data-sidebar-right-guide-entry="files"]:not([aria-keyshortcuts]) {
198
+ /* 0.25.0's dock fallback — the floating quick-open button the plugin pins
199
+ for empty workspaces. Same treatment as every other open-the-machine's-
200
+ files control: hidden while unbridged, untouched when bridged. */
201
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-dsh-dock-fallback] {
163
202
  display: none !important;
164
203
  }
165
204
 
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The plugin bridge's settings-page pure pieces (PB-5): the status → copy
3
+ * mapping the 子客户端诊断区 renders, and the consent-toggle arithmetic the
4
+ * 主服务端插件桥 section stages. Pure by the settings-form discipline — no
5
+ * fetch, no clock, no context — so scripts/check-plugin-bridge-ui.mjs pins
6
+ * every copy and every toggle shape.
7
+ *
8
+ * Deliberately import-free: this file rides the BROWSER bundle, and the
9
+ * bridge's own module (src/plugin-bridge.ts) drags node:crypto in. The
10
+ * status shape is therefore restated structurally here — it mirrors
11
+ * lib/plugin-bridge.js's `BridgeStatus` verbatim, and a drifted wire is
12
+ * handled by the tolerant mapping, not by the type system.
13
+ */
14
+
15
+ /** One plugin's bridge verdict, exactly the shape `client/status`'s
16
+ * `pluginBridge` array carries (PB-2). */
17
+ export interface PluginBridgeStatusLike {
18
+ plugin: string
19
+ bridged: boolean
20
+ reason?: string
21
+ localVersion: string
22
+ serverVersion?: string
23
+ }
24
+
25
+ /** One row of the 诊断区's 插件桥 group: the plugin, its LOCAL version, and
26
+ * the copy key (plus the interpolation value) the locale renders. */
27
+ export interface PluginBridgeStatusView {
28
+ plugin: string
29
+ localVersion: string
30
+ /** The locale key under `settings.bridge.state*`. */
31
+ stateKey: PluginBridgeStateKey
32
+ /** The server's version, when the verdict names one (版本不同's copy). */
33
+ serverVersion: string | undefined
34
+ }
35
+
36
+ export type PluginBridgeStateKey =
37
+ | 'settings.bridge.stateBridged'
38
+ | 'settings.bridge.stateNotOnServer'
39
+ | 'settings.bridge.stateVersionMismatch'
40
+ | 'settings.bridge.stateDeclarationMismatch'
41
+ | 'settings.bridge.stateConsentOff'
42
+ | 'settings.bridge.stateOffline'
43
+ | 'settings.bridge.stateServerTooOld'
44
+
45
+ /** The reason → copy-key table, one entry per BridgeReason plus the
46
+ * everything-else fallback (an unknown reason from a newer server reads as
47
+ * 未桥接-generic — the offline copy, honest without inventing a claim). */
48
+ const REASON_KEYS: Readonly<Record<string, PluginBridgeStateKey>> = {
49
+ 'not-on-server': 'settings.bridge.stateNotOnServer',
50
+ 'version-mismatch': 'settings.bridge.stateVersionMismatch',
51
+ 'declaration-mismatch': 'settings.bridge.stateDeclarationMismatch',
52
+ 'consent-off': 'settings.bridge.stateConsentOff',
53
+ offline: 'settings.bridge.stateOffline',
54
+ 'server-too-old': 'settings.bridge.stateServerTooOld',
55
+ }
56
+
57
+ /** Tolerant read of one `pluginBridge` wire array into the render rows,
58
+ * input order kept (the route already sorts by plugin name). A malformed
59
+ * entry is skipped, never thrown into the settings page. */
60
+ export function derivePluginBridgeStatuses(value: unknown): PluginBridgeStatusView[] {
61
+ if (!Array.isArray(value)) return []
62
+ const rows: PluginBridgeStatusView[] = []
63
+ for (const entry of value) {
64
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
65
+ const record = entry as Record<string, unknown>
66
+ if (typeof record.plugin !== 'string' || record.plugin === '') continue
67
+ const bridged = record.bridged === true
68
+ const reason = typeof record.reason === 'string' ? record.reason : undefined
69
+ rows.push({
70
+ plugin: record.plugin,
71
+ localVersion: typeof record.localVersion === 'string' ? record.localVersion : '',
72
+ stateKey: bridged
73
+ ? 'settings.bridge.stateBridged'
74
+ : reason !== undefined && REASON_KEYS[reason] !== undefined
75
+ ? REASON_KEYS[reason]
76
+ : 'settings.bridge.stateOffline',
77
+ serverVersion: typeof record.serverVersion === 'string' ? record.serverVersion : undefined,
78
+ })
79
+ }
80
+ return rows
81
+ }
82
+
83
+ /**
84
+ * The three-state resolution for the host settings page's 插件桥 list
85
+ * (PB-9-fix): a fetch that has not answered is LOADING, a failed or
86
+ * refused fetch is an ERROR — and only a successful answer with zero rows
87
+ * means "no plugin has registered a bridge". Collapsing the first two into
88
+ * the empty copy would tell the user nothing is bridged while the answer is
89
+ * simply not in yet (or the page was opened through the gateway, where the
90
+ * listing is refused). Pure so the check pins all four outcomes.
91
+ */
92
+ export function bridgeSectionState(load: 'loading' | 'error' | 'ready', rows: unknown): 'loading' | 'error' | 'empty' | 'plugins' {
93
+ if (load === 'loading') return 'loading'
94
+ if (load === 'error') return 'error'
95
+ return Array.isArray(rows) && rows.length > 0 ? 'plugins' : 'empty'
96
+ }
97
+
98
+ /**
99
+ * One consent-toggle click: the NEXT whole-machine-data consent map, staged
100
+ * over the row's `pluginBridgeConsent` field. Only `plugin`'s key moves —
101
+ * every other key (including entries for plugins no longer registered; the
102
+ * settings page deliberately never prunes what it does not render) is
103
+ * carried over verbatim, in its existing key order. An illegal current map
104
+ * (a hand-edited row) reads as the empty map: the toggle then writes exactly
105
+ * one key, and the junk is left for the row validator to keep refusing.
106
+ */
107
+ export function togglePluginConsent(current: unknown, plugin: string, allow: boolean): Record<string, boolean> {
108
+ const next: Record<string, boolean> = {}
109
+ if (current !== null && typeof current === 'object' && !Array.isArray(current)) {
110
+ for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
111
+ if (typeof value === 'boolean') next[key] = value
112
+ }
113
+ }
114
+ next[plugin] = allow
115
+ return next
116
+ }
117
+
118
+ /** One row of the host settings page's 插件桥 list, as the admin listing
119
+ * answers it (PB-5: plugin/version/hasServerScope/consent; PB-11 adds the
120
+ * adapter mark). */
121
+ export interface AdminBridgeRowView {
122
+ plugin: string
123
+ version: string
124
+ hasServerScope: boolean
125
+ consent: boolean
126
+ /** True when zen-remote's own adapter stands in for this plugin (the
127
+ * row renders「由 zen-remote 适配」); a wiring that predates adapters
128
+ * never sets it. */
129
+ adapted: boolean
130
+ }
131
+
132
+ /**
133
+ * Tolerant read of one admin listing row into the render shape (PB-11).
134
+ * A malformed row is `undefined`, never a throw — the settings page skips
135
+ * it the same way {@link derivePluginBridgeStatuses} skips a bad verdict.
136
+ * `adapted` defaults to false: an older server's answer simply reads as
137
+ * "nothing is adapted".
138
+ */
139
+ export function readAdminBridgeRow(row: unknown): AdminBridgeRowView | undefined {
140
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) return undefined
141
+ const record = row as Record<string, unknown>
142
+ if (typeof record.plugin !== 'string' || record.plugin === '') return undefined
143
+ return {
144
+ plugin: record.plugin,
145
+ version: typeof record.version === 'string' ? record.version : '',
146
+ hasServerScope: record.hasServerScope === true,
147
+ consent: record.consent === true,
148
+ adapted: record.adapted === true,
149
+ }
150
+ }