dsh-zen-remote 2.3.11 → 2.4.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 (249) hide show
  1. package/CHANGELOG.md +45 -78
  2. package/README.md +94 -84
  3. package/README.zh-CN.md +90 -81
  4. package/bin/zen-remote.mjs +5 -0
  5. package/docs/interface.md +6 -6
  6. package/docs/remote-access.en.md +6 -4
  7. package/docs/remote-access.md +6 -4
  8. package/dsh-push.mjs +96 -10
  9. package/lib/adapters/dsh-better-sidebar/index.js +7 -3
  10. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  11. package/lib/admin-routes.js +10 -2
  12. package/lib/admin-routes.js.map +1 -1
  13. package/lib/cli/args.js +251 -0
  14. package/lib/cli/args.js.map +1 -0
  15. package/lib/cli/bootstrap.js +449 -0
  16. package/lib/cli/bootstrap.js.map +1 -0
  17. package/lib/cli/command-setup.js +484 -0
  18. package/lib/cli/command-setup.js.map +1 -0
  19. package/lib/cli/commands-client.js +359 -0
  20. package/lib/cli/commands-client.js.map +1 -0
  21. package/lib/cli/commands-host.js +1108 -0
  22. package/lib/cli/commands-host.js.map +1 -0
  23. package/lib/cli/commands-status.js +279 -0
  24. package/lib/cli/commands-status.js.map +1 -0
  25. package/lib/cli/config-writer.js +334 -0
  26. package/lib/cli/config-writer.js.map +1 -0
  27. package/lib/cli/context.js +10 -0
  28. package/lib/cli/context.js.map +1 -0
  29. package/lib/cli/i18n.js +620 -0
  30. package/lib/cli/i18n.js.map +1 -0
  31. package/lib/cli/ipc-client.js +160 -0
  32. package/lib/cli/ipc-client.js.map +1 -0
  33. package/lib/cli/ipc-server.js +464 -0
  34. package/lib/cli/ipc-server.js.map +1 -0
  35. package/lib/cli/main.js +245 -0
  36. package/lib/cli/main.js.map +1 -0
  37. package/lib/cli/qr-terminal.js +48 -0
  38. package/lib/cli/qr-terminal.js.map +1 -0
  39. package/lib/client-data/qr.js +286 -0
  40. package/lib/client-data/qr.js.map +1 -0
  41. package/lib/client-routes.js +308 -25
  42. package/lib/client-routes.js.map +1 -1
  43. package/lib/client.js +1013 -229
  44. package/lib/config.js +50 -12
  45. package/lib/config.js.map +1 -1
  46. package/lib/debug-report.js +28 -1
  47. package/lib/debug-report.js.map +1 -1
  48. package/lib/fetch-route-intercept.js +334 -26
  49. package/lib/fetch-route-intercept.js.map +1 -1
  50. package/lib/index.js +608 -375
  51. package/lib/index.js.map +1 -1
  52. package/lib/intercept.js +1833 -280
  53. package/lib/intercept.js.map +1 -1
  54. package/lib/lan-gate-server.cjs +36 -10
  55. package/lib/merge-streams.js +103 -11
  56. package/lib/merge-streams.js.map +1 -1
  57. package/lib/plugin-route-intercept.js +93 -63
  58. package/lib/plugin-route-intercept.js.map +1 -1
  59. package/lib/relay-access.js +8 -1
  60. package/lib/relay-access.js.map +1 -1
  61. package/lib/relay-client.js +133 -4
  62. package/lib/relay-client.js.map +1 -1
  63. package/lib/relay-pool.js +275 -0
  64. package/lib/relay-pool.js.map +1 -0
  65. package/lib/relay-server.js +269 -17
  66. package/lib/relay-server.js.map +1 -1
  67. package/lib/restart-fields.js +6 -4
  68. package/lib/restart-fields.js.map +1 -1
  69. package/lib/share-ops.js +68 -0
  70. package/lib/share-ops.js.map +1 -1
  71. package/lib/share-store.js +65 -27
  72. package/lib/share-store.js.map +1 -1
  73. package/lib/state-store.js +400 -0
  74. package/lib/state-store.js.map +1 -0
  75. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
  76. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  77. package/lib/types/admin-routes.d.ts.map +1 -1
  78. package/lib/types/cli/args.d.ts +78 -0
  79. package/lib/types/cli/args.d.ts.map +1 -0
  80. package/lib/types/cli/bootstrap.d.ts +170 -0
  81. package/lib/types/cli/bootstrap.d.ts.map +1 -0
  82. package/lib/types/cli/command-setup.d.ts +72 -0
  83. package/lib/types/cli/command-setup.d.ts.map +1 -0
  84. package/lib/types/cli/commands-client.d.ts +36 -0
  85. package/lib/types/cli/commands-client.d.ts.map +1 -0
  86. package/lib/types/cli/commands-host.d.ts +82 -0
  87. package/lib/types/cli/commands-host.d.ts.map +1 -0
  88. package/lib/types/cli/commands-status.d.ts +46 -0
  89. package/lib/types/cli/commands-status.d.ts.map +1 -0
  90. package/lib/types/cli/config-writer.d.ts +92 -0
  91. package/lib/types/cli/config-writer.d.ts.map +1 -0
  92. package/lib/types/cli/context.d.ts +39 -0
  93. package/lib/types/cli/context.d.ts.map +1 -0
  94. package/lib/types/cli/i18n.d.ts +32 -0
  95. package/lib/types/cli/i18n.d.ts.map +1 -0
  96. package/lib/types/cli/ipc-client.d.ts +83 -0
  97. package/lib/types/cli/ipc-client.d.ts.map +1 -0
  98. package/lib/types/cli/ipc-server.d.ts +113 -0
  99. package/lib/types/cli/ipc-server.d.ts.map +1 -0
  100. package/lib/types/cli/main.d.ts +57 -0
  101. package/lib/types/cli/main.d.ts.map +1 -0
  102. package/lib/types/cli/qr-terminal.d.ts +17 -0
  103. package/lib/types/cli/qr-terminal.d.ts.map +1 -0
  104. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
  105. package/lib/types/client/current-session-report.d.ts +4 -0
  106. package/lib/types/client/current-session-report.d.ts.map +1 -1
  107. package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
  108. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  109. package/lib/types/client/locales.d.ts +47 -3
  110. package/lib/types/client/locales.d.ts.map +1 -1
  111. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  112. package/lib/types/client/settings/settings-css.d.ts +1 -1
  113. package/lib/types/client/settings/settings-css.d.ts.map +1 -1
  114. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  115. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  116. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  117. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
  118. package/lib/types/client/wizard/wizard-css.d.ts +1 -1
  119. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
  120. package/lib/types/client-data/model-group-side.d.ts +23 -2
  121. package/lib/types/client-data/model-group-side.d.ts.map +1 -1
  122. package/lib/types/client-data/remote-status.d.ts +47 -1
  123. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  124. package/lib/types/client-data/settings-form.d.ts +85 -1
  125. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  126. package/lib/types/client-data/wizard.d.ts +56 -1
  127. package/lib/types/client-data/wizard.d.ts.map +1 -1
  128. package/lib/types/client-routes.d.ts +25 -1
  129. package/lib/types/client-routes.d.ts.map +1 -1
  130. package/lib/types/config.d.ts +40 -14
  131. package/lib/types/config.d.ts.map +1 -1
  132. package/lib/types/debug-report.d.ts.map +1 -1
  133. package/lib/types/fetch-route-intercept.d.ts +66 -8
  134. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  135. package/lib/types/index.d.ts +9 -2
  136. package/lib/types/index.d.ts.map +1 -1
  137. package/lib/types/intercept.d.ts +72 -10
  138. package/lib/types/intercept.d.ts.map +1 -1
  139. package/lib/types/merge-streams.d.ts +79 -1
  140. package/lib/types/merge-streams.d.ts.map +1 -1
  141. package/lib/types/plugin-route-intercept.d.ts +25 -9
  142. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  143. package/lib/types/relay-access.d.ts +3 -1
  144. package/lib/types/relay-access.d.ts.map +1 -1
  145. package/lib/types/relay-client.d.ts +23 -2
  146. package/lib/types/relay-client.d.ts.map +1 -1
  147. package/lib/types/relay-pool.d.ts +148 -0
  148. package/lib/types/relay-pool.d.ts.map +1 -0
  149. package/lib/types/relay-server.d.ts +30 -3
  150. package/lib/types/relay-server.d.ts.map +1 -1
  151. package/lib/types/restart-fields.d.ts +5 -5
  152. package/lib/types/restart-fields.d.ts.map +1 -1
  153. package/lib/types/share-ops.d.ts +47 -1
  154. package/lib/types/share-ops.d.ts.map +1 -1
  155. package/lib/types/share-store.d.ts.map +1 -1
  156. package/lib/types/state-store.d.ts +179 -0
  157. package/lib/types/state-store.d.ts.map +1 -0
  158. package/lib/types/upload-limits.d.ts +15 -0
  159. package/lib/types/upload-limits.d.ts.map +1 -1
  160. package/lib/types/virtual-id.d.ts +21 -0
  161. package/lib/types/virtual-id.d.ts.map +1 -1
  162. package/lib/types/wizard/probe.d.ts.map +1 -1
  163. package/lib/types/wizard/proxy-snippets.d.ts +3 -0
  164. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  165. package/lib/types/wizard/routes.d.ts +1 -0
  166. package/lib/types/wizard/routes.d.ts.map +1 -1
  167. package/lib/types/wizard/status.d.ts +9 -0
  168. package/lib/types/wizard/status.d.ts.map +1 -1
  169. package/lib/types/wizard/verify.d.ts +3 -0
  170. package/lib/types/wizard/verify.d.ts.map +1 -1
  171. package/lib/upload-limits.js +15 -0
  172. package/lib/upload-limits.js.map +1 -1
  173. package/lib/virtual-id.js +60 -0
  174. package/lib/virtual-id.js.map +1 -1
  175. package/lib/wizard/probe.js +46 -1
  176. package/lib/wizard/probe.js.map +1 -1
  177. package/lib/wizard/proxy-snippets.js +14 -1
  178. package/lib/wizard/proxy-snippets.js.map +1 -1
  179. package/lib/wizard/routes.js +23 -4
  180. package/lib/wizard/routes.js.map +1 -1
  181. package/lib/wizard/status.js +21 -0
  182. package/lib/wizard/status.js.map +1 -1
  183. package/lib/wizard/verify.js +31 -8
  184. package/lib/wizard/verify.js.map +1 -1
  185. package/package.json +8 -4
  186. package/pwa/icons/badge-96.png +0 -0
  187. package/pwa/icons/badge.svg +1 -0
  188. package/pwa/manifest.json +2 -1
  189. package/pwa/sw.js +3 -1
  190. package/scripts/check-debug-report.mjs +41 -1
  191. package/scripts/check-settings-form.mjs +14 -3
  192. package/scripts/check-settings-view.mjs +247 -0
  193. package/scripts/check-wizard-ui.mjs +37 -0
  194. package/src/adapters/dsh-better-sidebar/index.ts +7 -3
  195. package/src/admin-routes.ts +10 -2
  196. package/src/cli/args.ts +289 -0
  197. package/src/cli/bootstrap.ts +569 -0
  198. package/src/cli/command-setup.ts +540 -0
  199. package/src/cli/commands-client.ts +369 -0
  200. package/src/cli/commands-host.ts +1127 -0
  201. package/src/cli/commands-status.ts +296 -0
  202. package/src/cli/config-writer.ts +354 -0
  203. package/src/cli/context.ts +38 -0
  204. package/src/cli/i18n.ts +645 -0
  205. package/src/cli/ipc-client.ts +216 -0
  206. package/src/cli/ipc-server.ts +531 -0
  207. package/src/cli/main.ts +285 -0
  208. package/src/cli/qr-terminal.ts +49 -0
  209. package/src/client/RemoteStatusIcon.tsx +4 -1
  210. package/src/client/current-session-report.ts +28 -4
  211. package/src/client/effects/model-group-side.ts +19 -5
  212. package/src/client/effects/remote-session.ts +26 -6
  213. package/src/client/locales.ts +96 -6
  214. package/src/client/settings/SettingsSection.tsx +287 -92
  215. package/src/client/settings/settings-css.ts +18 -0
  216. package/src/client/styles/remote-session.css.ts +15 -1
  217. package/src/client/wizard/GatewayWizard.tsx +470 -109
  218. package/src/client/wizard/cards/ProxyCard.tsx +2 -1
  219. package/src/client/wizard/wizard-css.ts +23 -0
  220. package/src/client-data/model-group-side.ts +37 -10
  221. package/src/client-data/remote-status.ts +112 -17
  222. package/src/client-data/settings-form.ts +164 -8
  223. package/src/client-data/wizard.ts +112 -2
  224. package/src/client-routes.ts +334 -26
  225. package/src/config.ts +62 -16
  226. package/src/debug-report.ts +27 -1
  227. package/src/fetch-route-intercept.ts +381 -32
  228. package/src/index.ts +608 -380
  229. package/src/intercept.ts +1866 -286
  230. package/src/merge-streams.ts +155 -10
  231. package/src/plugin-route-intercept.ts +120 -71
  232. package/src/relay-access.ts +14 -3
  233. package/src/relay-client.ts +163 -5
  234. package/src/relay-pool.ts +387 -0
  235. package/src/relay-server.ts +312 -16
  236. package/src/restart-fields.ts +6 -4
  237. package/src/share-ops.ts +82 -1
  238. package/src/share-store.ts +59 -26
  239. package/src/state-store.ts +428 -0
  240. package/src/upload-limits.ts +16 -0
  241. package/src/virtual-id.ts +57 -0
  242. package/src/wizard/probe.ts +54 -2
  243. package/src/wizard/proxy-snippets.ts +17 -2
  244. package/src/wizard/routes.ts +24 -5
  245. package/src/wizard/status.ts +28 -0
  246. package/src/wizard/verify.ts +33 -11
  247. package/AGENTS.md +0 -185
  248. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
  249. package/docs/adr/0002-relay-emulates-websocket.md +0 -8
@@ -23,7 +23,8 @@ export interface ProxyCardProps {
23
23
 
24
24
  export function ProxyCard({ t, domain, upstreamPort, onCheck, onClose }: ProxyCardProps): ReactNode {
25
25
  const [tab, setTab] = useState<SnippetKind>('nginx')
26
- const input = { domain, upstreamHost: '127.0.0.1', upstreamPort }
26
+ const lang: 'zh' | 'en' = t('wizard.card.close') === 'Close' ? 'en' : 'zh'
27
+ const input = { domain, upstreamHost: '127.0.0.1', upstreamPort, lang }
27
28
  const text = tab === 'nginx'
28
29
  ? nginxSnippetText(input)
29
30
  : tab === 'caddy' ? caddySnippetText(input) : (luckyStepTexts(input) ?? []).join('\n')
@@ -138,6 +138,18 @@ export const WIZARD_CSS = `
138
138
  overflow-wrap: anywhere;
139
139
  word-break: break-word;
140
140
  }
141
+ /* MS-8: the pool cap's loud form — the 已配对服务端 cell's value and sub
142
+ wear data-limit="true" at 8/8 and turn bold red (the same loudness the
143
+ settings page's limit alert uses). */
144
+ [data-zen-remote="wizard"] .zr-wizard-overviewValue[data-limit="true"] {
145
+ color: var(--dsw-alias-state-error-primary, #d5304f);
146
+ font-weight: 700;
147
+ }
148
+ [data-zen-remote="wizard"] .zr-wizard-overviewSub[data-limit="true"],
149
+ [data-zen-remote="wizard-modal"] .zr-wizard-subtitle[data-limit="true"] {
150
+ color: var(--dsw-alias-state-error-primary, #d5304f);
151
+ font-weight: 600;
152
+ }
141
153
  [data-zen-remote="wizard"] .zr-wizard-emptyBanner {
142
154
  display: flex;
143
155
  align-items: center;
@@ -773,6 +785,17 @@ export const WIZARD_CSS = `
773
785
  background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-module-platform));
774
786
  color: var(--dsw-alias-label-secondary);
775
787
  }
788
+ /* MS-8: the servers-modal state badge tones — green online, amber offline,
789
+ red for a verdict only a re-pair or an upgrade fixes. */
790
+ [data-zen-remote="wizard"] .zr-wizard-deviceBadge[data-tone="live"] {
791
+ color: var(--dsw-alias-state-success-primary, #16a34a);
792
+ }
793
+ [data-zen-remote="wizard"] .zr-wizard-deviceBadge[data-tone="warn"] {
794
+ color: var(--dsw-alias-state-warning-primary, #b8860b);
795
+ }
796
+ [data-zen-remote="wizard"] .zr-wizard-deviceBadge[data-tone="invalid"] {
797
+ color: var(--dsw-alias-state-error-primary, #d5304f);
798
+ }
776
799
  [data-zen-remote="wizard"] .zr-wizard-deviceActions {
777
800
  display: flex;
778
801
  align-items: center;
@@ -28,7 +28,13 @@
28
28
  * counts as a mark, so it can never decide a container by accident.
29
29
  */
30
30
 
31
- import { VIRTUAL_GROUP_NAME_MARK } from '../virtual-id.ts'
31
+ import { VIRTUAL_GROUP_NAME_MARK, decodeServerIdZeroWidth } from '../virtual-id.ts'
32
+
33
+ /** MS-4: the <html> attribute carrying the open REMOTE session's server id
34
+ * (`zr~<serverId>~…`'s middle half). Written by effects/remote-session.ts
35
+ * together with `data-zr-remote-session`, read by
36
+ * effects/model-group-side.ts as the `activeRemoteServerId`. */
37
+ export const REMOTE_SERVER_ATTRIBUTE = 'data-zr-remote-server'
32
38
 
33
39
  /** One group's display description, as the DOM shows it. */
34
40
  export interface ModelGroupHeading {
@@ -42,10 +48,29 @@ export interface ModelGroupHeading {
42
48
  /** Per-group verdict, in input order: `remote` for marked headings, `local`
43
49
  * for the rest of a container that has at least one, and `undefined` —
44
50
  * "stamp nothing" — for every group of a container that holds no mark at
45
- * all. */
46
- export type ModelGroupSide = 'remote' | 'local' | undefined
51
+ * all. MS-4 adds `remote-other`: with a session's own server id known
52
+ * ({@link classifyModelGroupsForSession}'s `activeRemoteServerId`), a
53
+ * marked group whose DECODED server id is a DIFFERENT paired server — the
54
+ * model menu must show only the models of the server the open session
55
+ * lives on. */
56
+ export type ModelGroupSide = 'remote' | 'local' | 'remote-other' | undefined
47
57
 
48
58
  export function classifyModelGroups(groups: readonly ModelGroupHeading[]): ModelGroupSide[] {
59
+ return classifyModelGroupsForSession(groups)
60
+ }
61
+
62
+ /**
63
+ * The MS-4 extension: with `activeRemoteServerId` set (the `zr~<serverId>~`
64
+ * half of the session the browser has open, effects/remote-session.ts),
65
+ * each marked heading's embedded server id (decodeServerIdZeroWidth)
66
+ * decides between plain `remote` (the open session's own server, or a
67
+ * heading whose server cannot be decoded — shown, never hidden) and
68
+ * `remote-other` (a DIFFERENT paired server's group — hidden in a remote
69
+ * session by remote-session.css.ts). Without the argument the verdicts
70
+ * are exactly {@link classifyModelGroups}'s — the single-paired-server
71
+ * behavior, byte for byte.
72
+ */
73
+ export function classifyModelGroupsForSession(groups: readonly ModelGroupHeading[], activeRemoteServerId?: string): ModelGroupSide[] {
49
74
  // Per container: did any heading carry the mark? Containers are decided
50
75
  // independently — one mixed model menu must not turn an all-local
51
76
  // container (a second menu elsewhere on the page) into "hide everything
@@ -55,11 +80,13 @@ export function classifyModelGroups(groups: readonly ModelGroupHeading[]): Model
55
80
  const had = marked.get(group.containerKey) ?? false
56
81
  marked.set(group.containerKey, had || group.headingText.startsWith(VIRTUAL_GROUP_NAME_MARK))
57
82
  }
58
- return groups.map((group) =>
59
- marked.get(group.containerKey)
60
- ? group.headingText.startsWith(VIRTUAL_GROUP_NAME_MARK)
61
- ? 'remote'
62
- : 'local'
63
- : undefined,
64
- )
83
+ return groups.map((group) => {
84
+ if (!marked.get(group.containerKey)) return undefined
85
+ if (!group.headingText.startsWith(VIRTUAL_GROUP_NAME_MARK)) return 'local'
86
+ if (activeRemoteServerId !== undefined) {
87
+ const groupServerId = decodeServerIdZeroWidth(group.headingText)
88
+ if (groupServerId !== undefined && groupServerId !== activeRemoteServerId) return 'remote-other'
89
+ }
90
+ return 'remote'
91
+ })
65
92
  }
@@ -8,6 +8,10 @@
8
8
  * - `parseRemoteStatusBody(body, now)` maps the
9
9
  * `GET /_dsh/zen-remote/client/remote-status` body
10
10
  * (`{ state, versionMismatch, serverName, closed }`) into a tolerant view;
11
+ * since MS-6 the body's `servers` dict (per handshake serverId) parses
12
+ * alongside, and `resolveServerStatusView(view, sessionId)` picks the
13
+ * session's OWN server entry — falling back to the top-level mirror (the
14
+ * FIRST pool entry) whenever the dict cannot answer;
11
15
  * - `describeRemoteStatus(view)` maps one view to the icon's three states —
12
16
  * online (lit) / offline (grey; `revoked` / `unpaired` count as offline —
13
17
  * the link is not serving either way) / mismatch (yellow, only ever while
@@ -29,7 +33,7 @@
29
33
  */
30
34
 
31
35
  import { createLatestGate } from './settings-form.ts'
32
- import { isVirtual } from '../virtual-id.ts'
36
+ import { fromVirtual, isVirtual } from '../virtual-id.ts'
33
37
  import { zh } from '../client/locales.ts'
34
38
 
35
39
  /** Same-origin client route feeding this store (host half: T34,
@@ -47,6 +51,21 @@ export type RemoteStatusState = 'online' | 'offline' | 'revoked' | 'unpaired'
47
51
  /** Why the server closed a session's remote access. */
48
52
  export type RemoteClosedReason = 'manual' | 'client' | 'idle'
49
53
 
54
+ /**
55
+ * ONE paired server's serving state (MS-6): the same four facts the top
56
+ * level has always carried, scoped to the server whose handshake minted the
57
+ * `serverId`. The route (src/client-routes.ts, MS-5) serves these under
58
+ * `servers`, keyed by serverId; the top level mirrors the FIRST pool entry
59
+ * for the pre-MS-6 page.
60
+ */
61
+ export interface RemoteServerStatusView {
62
+ state: RemoteStatusState
63
+ versionMismatch: boolean
64
+ serverName: string
65
+ /** The plugins whose bridge verdict is bridged RIGHT NOW on THIS server. */
66
+ bridgedPlugins: readonly string[]
67
+ }
68
+
50
69
  /** One parsed status body, stamped with the parse time for future decay. */
51
70
  export interface RemoteStatusView {
52
71
  state: RemoteStatusState
@@ -58,6 +77,10 @@ export interface RemoteStatusView {
58
77
  * nothing is ever considered bridged on a shape the parse does not
59
78
  * trust. */
60
79
  bridgedPlugins: readonly string[]
80
+ /** Per-server views keyed by the handshake serverId (MS-6). A legacy body
81
+ * without the field parses to the empty dict — the parts then fall back to
82
+ * the top-level mirror. */
83
+ servers: Readonly<Record<string, RemoteServerStatusView>>
61
84
  /** When this view was parsed off a GET body. */
62
85
  asOf: number
63
86
  }
@@ -69,12 +92,26 @@ export interface RemoteStatusSnapshot {
69
92
  view: RemoteStatusView | undefined
70
93
  }
71
94
 
95
+ /** The tolerant per-entry parse the top level and the `servers` dict share:
96
+ * a garbage shape is "not online yet", a garbage field degrades to its
97
+ * default — never a throw into the polling loop. */
98
+ function parseServerStatusEntry(value: unknown): RemoteServerStatusView {
99
+ const record = value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
100
+ return {
101
+ state: record.state === 'online' || record.state === 'revoked' || record.state === 'unpaired' ? record.state : 'offline',
102
+ versionMismatch: record.versionMismatch === true,
103
+ serverName: typeof record.serverName === 'string' ? record.serverName : '',
104
+ bridgedPlugins: Array.isArray(record.bridgedPlugins)
105
+ ? record.bridgedPlugins.filter((name): name is string => typeof name === 'string' && name !== '')
106
+ : [],
107
+ }
108
+ }
109
+
72
110
  /** Parse one GET body tolerantly: a garbage shape is "not online yet", a
73
111
  * garbage field degrades to its default — never a throw into the polling
74
112
  * loop. */
75
113
  export function parseRemoteStatusBody(body: unknown, now: number): RemoteStatusView {
76
114
  const record = body !== null && typeof body === 'object' ? (body as Record<string, unknown>) : {}
77
- const state = record.state === 'online' || record.state === 'revoked' || record.state === 'unpaired' ? record.state : 'offline'
78
115
  const closed: Record<string, RemoteClosedReason> = {}
79
116
  if (record.closed !== null && typeof record.closed === 'object') {
80
117
  for (const [sessionId, reason] of Object.entries(record.closed as Record<string, unknown>)) {
@@ -82,14 +119,20 @@ export function parseRemoteStatusBody(body: unknown, now: number): RemoteStatusV
82
119
  closed[sessionId] = reason === 'client' || reason === 'idle' ? reason : 'manual'
83
120
  }
84
121
  }
122
+ // MS-6: the per-server dict, keyed by the handshake serverId. A legacy
123
+ // body (or a broken one) parses to the EMPTY dict — the parts then read
124
+ // the top-level mirror, the pre-MS-6 answer.
125
+ const servers: Record<string, RemoteServerStatusView> = {}
126
+ if (record.servers !== null && typeof record.servers === 'object' && !Array.isArray(record.servers)) {
127
+ for (const [serverId, entry] of Object.entries(record.servers as Record<string, unknown>)) {
128
+ if (serverId === '') continue
129
+ servers[serverId] = parseServerStatusEntry(entry)
130
+ }
131
+ }
85
132
  return {
86
- state,
87
- versionMismatch: record.versionMismatch === true,
88
- serverName: typeof record.serverName === 'string' ? record.serverName : '',
133
+ ...parseServerStatusEntry(record),
89
134
  closed,
90
- bridgedPlugins: Array.isArray(record.bridgedPlugins)
91
- ? record.bridgedPlugins.filter((name): name is string => typeof name === 'string' && name !== '')
92
- : [],
135
+ servers,
93
136
  asOf: now,
94
137
  }
95
138
  }
@@ -110,6 +153,48 @@ export function shouldStampBridgeAttribute(bridgedPlugins: unknown, plugin: stri
110
153
  return Array.isArray(bridgedPlugins) && bridgedPlugins.includes(plugin)
111
154
  }
112
155
 
156
+ /**
157
+ * Which server's facts ONE session reads (MS-6): given the session id on
158
+ * screen, its virtual form names the owning serverId, and THAT server's
159
+ * entry answers — per-server isolation for the icon, the banner and the
160
+ * bridge stamp. Everything else falls back to the TOP-LEVEL view, the
161
+ * pre-MS-6 answer: a session id that is not virtual (a local session), a
162
+ * serverId the dict never answered (its entry never handshook), a legacy
163
+ * body without a dict, or no session id at all. Tolerant the same way as
164
+ * the parse: hand-rolled views without `servers` never throw here.
165
+ */
166
+ export function resolveServerStatusView(
167
+ view: RemoteStatusView | undefined | null,
168
+ sessionId?: string,
169
+ ): RemoteServerStatusView | undefined {
170
+ if (view === undefined || view === null) return undefined
171
+ if (sessionId !== undefined) {
172
+ const parts = fromVirtual(sessionId)
173
+ if (parts !== undefined) {
174
+ const server = view.servers?.[parts.serverId]
175
+ if (server !== undefined) return server
176
+ }
177
+ }
178
+ // RemoteStatusView structurally carries every RemoteServerStatusView field.
179
+ return view
180
+ }
181
+
182
+ /**
183
+ * Whether the remote session CURRENTLY ON SCREEN has its own server's bridge
184
+ * verdict going its way (MS-6): the verdict comes from the session's server
185
+ * entry ({@link resolveServerStatusView}), not the page-wide mirror of the
186
+ * FIRST pool entry — with several servers paired, A bridging a plugin must
187
+ * not un-hide it over B's sessions. Tolerant like the 2-argument core it
188
+ * wraps: a garbage or missing list reads as "not bridged".
189
+ */
190
+ export function shouldStampBridgeForSession(
191
+ view: RemoteStatusView | undefined,
192
+ sessionId: string | undefined,
193
+ plugin: string,
194
+ ): boolean {
195
+ return shouldStampBridgeAttribute(resolveServerStatusView(view, sessionId)?.bridgedPlugins, plugin)
196
+ }
197
+
113
198
  /** The plugin the bridge stamp names. */
114
199
  export const BRIDGE_STAMP_PLUGIN = 'dsh-better-sidebar'
115
200
 
@@ -149,12 +234,17 @@ export interface RemoteStatusDescription {
149
234
  export function describeRemoteStatus(
150
235
  view: RemoteStatusView | undefined | null,
151
236
  t: RemoteStatusFormatter = createZhRemoteStatusFormatter(),
237
+ /** MS-6: the session on screen — its virtual id picks its OWN server's
238
+ * entry; omitted (or unanswered by the dict) keeps the pre-MS-6 top-level
239
+ * read. */
240
+ sessionId?: string,
152
241
  ): RemoteStatusDescription {
153
- if (view === undefined || view === null) return { state: 'offline', hoverText: t('remoteStatusOffline') }
154
- if (view.state === 'revoked') return { state: 'revoked', hoverText: t('remoteStatusRevoked') }
155
- if (view.state === 'unpaired') return { state: 'unpaired', hoverText: t('remoteStatusUnpaired') }
156
- if (view.state === 'online') {
157
- return view.versionMismatch
242
+ const status = resolveServerStatusView(view, sessionId)
243
+ if (status === undefined) return { state: 'offline', hoverText: t('remoteStatusOffline') }
244
+ if (status.state === 'revoked') return { state: 'revoked', hoverText: t('remoteStatusRevoked') }
245
+ if (status.state === 'unpaired') return { state: 'unpaired', hoverText: t('remoteStatusUnpaired') }
246
+ if (status.state === 'online') {
247
+ return status.versionMismatch
158
248
  ? { state: 'mismatch', hoverText: t('remoteStatusMismatch') }
159
249
  : { state: 'online', hoverText: t('remoteStatusOnline') }
160
250
  }
@@ -190,18 +280,23 @@ export function bannerText(
190
280
  sessionId: string,
191
281
  t: RemoteBannerFormatter = (key) => zh[key],
192
282
  ): string | undefined {
283
+ // The closed map is SESSION-keyed (virtual ids embed their server, so one
284
+ // global dict cannot collide) and outranks every link-level line.
193
285
  const reason = view?.closed[sessionId]
194
286
  if (reason === 'idle') return t('remoteBannerClosedIdle')
195
287
  if (reason === 'client') return t('remoteBannerClosedClient')
196
288
  // 'manual' — and any word this build does not know (a newer server's
197
289
  // vocabulary): a closure with no certain reason reads as the manual close.
198
290
  if (reason !== undefined) return t('remoteBannerClosedManual')
199
- if (view !== undefined && view !== null) {
200
- if (view.state === 'offline') return t('remoteBannerOffline')
291
+ // MS-6: the link-level line reads the SESSION'S OWN server, not the page's
292
+ // first-entry mirror.
293
+ const status = resolveServerStatusView(view, sessionId)
294
+ if (status !== undefined) {
295
+ if (status.state === 'offline') return t('remoteBannerOffline')
201
296
  // T41a-fix2: a revoked token / an unpaired client is not "reconnecting"
202
297
  // — the composer must not sit disabled on a promise no reconnect keeps.
203
- if (view.state === 'revoked') return t('remoteBannerRevoked')
204
- if (view.state === 'unpaired') return t('remoteBannerUnpaired')
298
+ if (status.state === 'revoked') return t('remoteBannerRevoked')
299
+ if (status.state === 'unpaired') return t('remoteBannerUnpaired')
205
300
  }
206
301
  return undefined
207
302
  }
@@ -97,10 +97,22 @@ export const CLIENT_CLAIM_ROUTE = '/_dsh/zen-remote/client/claim'
97
97
  export const CLIENT_STATUS_ROUTE = '/_dsh/zen-remote/client/status'
98
98
  /** T43: one immediate reconnect — answered 409 unless the client is offline. */
99
99
  export const CLIENT_RECONNECT_ROUTE = '/_dsh/zen-remote/client/reconnect'
100
+ /** MS-6: POST `{ entryId }` (or `{ all: true }`) removes one / all paired
101
+ * servers from the pool (host half: src/client-routes.ts, mirrored here
102
+ * because the browser half cannot import the host module). */
103
+ export const CLIENT_UNPAIR_ROUTE = '/_dsh/zen-remote/client/unpair'
100
104
  /** CU-4: the redacted diagnostic report (GET, both roles, machine-local only
101
105
  * — a gateway-forwarded fetch is refused server-side). */
102
106
  export const CLIENT_DEBUG_REPORT_ROUTE = '/_dsh/zen-remote/client/debug-report'
103
107
 
108
+ /**
109
+ * MS-6: the paired-server pool's cap — the browser mirror of
110
+ * src/state-store.ts's constant of the same name (the host module cannot be
111
+ * imported here). The settings page and the wizard read it for the
112
+ * `已配对 N / 8 台` counters and the limit gating.
113
+ */
114
+ export const MAX_PAIRED_SERVERS = 8
115
+
104
116
  /** The lightweight client-facing config route (host half, both roles): the
105
117
  * settings page's FALLBACK role probe (T17) — it registers wherever a
106
118
  * webServer exists, and since T17 its body carries the effective `role`
@@ -169,6 +181,12 @@ export interface ClientStatusBody {
169
181
  /** RD-6: the upload-redirect service probe (presence-gated — an older
170
182
  * plugin answers without it). */
171
183
  serviceProbe?: unknown
184
+ /** MS-6: the pool list, one row per paired server (`poolStatusBodyOf`'s
185
+ * `servers`), presence-gated like every diagnostic field — a pre-MS-5
186
+ * server (or the row-probe fallback) answers without it. */
187
+ servers?: unknown
188
+ /** MS-6: the pool cap as the body reports it (8). */
189
+ maxServers?: unknown
172
190
  }
173
191
 
174
192
  /** One remote-call failure line in the diagnostics lists. `time` is epoch
@@ -201,6 +219,28 @@ export interface ClientCompatView {
201
219
  recentCalls: ClientDiagFailureView[]
202
220
  }
203
221
 
222
+ /**
223
+ * MS-6: ONE paired server of the pool, as the per-server list renders it —
224
+ * the parsed form of one `client/status` `servers[]` row (no token ever
225
+ * rides any status answer). `state` speaks the same view vocabulary as the
226
+ * top level (the wire aliases mapped); every field degrades to its default
227
+ * on a junk row, and a row without an `entryId` (the route's stable key)
228
+ * drops whole.
229
+ */
230
+ export interface ClientServerView {
231
+ entryId: string
232
+ serverId: string
233
+ serverUrl: string
234
+ serverName: string
235
+ /** The pool's deduplicated display name (`-1` / `-2` suffixes included). */
236
+ displayName: string
237
+ state: ClientConnectionView['state']
238
+ nextRetryAt: number | undefined
239
+ lastError: string
240
+ compat: ClientCompatView | undefined
241
+ pluginBridge: PluginBridgeStatusView[]
242
+ }
243
+
204
244
  /** One client connection as the block renders it. */
205
245
  export interface ClientConnectionView {
206
246
  state: 'unpaired' | 'connected' | 'revoked' | 'unreachable' | 'unexpected' | 'invalid-url' | 'incompatible'
@@ -227,6 +267,15 @@ export interface ClientConnectionView {
227
267
  * through tolerantly (undefined when the body carried none — an older
228
268
  * plugin — and the diagnostics row stays hidden). */
229
269
  uploadProbe: Readonly<Record<string, unknown>> | undefined
270
+ /** MS-6: every paired server of the pool, in pairing order. Empty on a
271
+ * legacy body (or a row-less unpaired answer) — the pre-MS-6 page shape. */
272
+ servers: readonly ClientServerView[]
273
+ /** MS-6: the pool's cap, from the body when it carried a sane number,
274
+ * {@link MAX_PAIRED_SERVERS} otherwise. */
275
+ maxServers: number
276
+ /** MS-6: `servers.length >= maxServers` — the red 已配满 note and the
277
+ * new-address submit gate both read this. */
278
+ limitReached: boolean
230
279
  }
231
280
 
232
281
  const CLIENT_STATES: readonly ClientConnectionView['state'][] = ['unpaired', 'connected', 'revoked', 'unreachable', 'unexpected', 'invalid-url', 'incompatible']
@@ -240,6 +289,42 @@ const WIRE_STATE_ALIASES: Readonly<Record<string, ClientConnectionView['state']>
240
289
  offline: 'unreachable',
241
290
  }
242
291
 
292
+ /** The view `state` for one raw wire word: the vocabulary verbatim, the T55
293
+ * aliases mapped, anything else the conservative `unpaired`. Shared by the
294
+ * top level and every `servers[]` row. */
295
+ function clientStateOf(raw: unknown): ClientConnectionView['state'] {
296
+ const word = typeof raw === 'string' ? raw : ''
297
+ return CLIENT_STATES.includes(word as ClientConnectionView['state'])
298
+ ? word as ClientConnectionView['state']
299
+ : WIRE_STATE_ALIASES[word] ?? 'unpaired'
300
+ }
301
+
302
+ /** The finite-number gate every epoch-ms wire field passes. */
303
+ function finiteOrUndefined(value: unknown): number | undefined {
304
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
305
+ }
306
+
307
+ /** MS-6: one `servers[]` row into its view. Tolerant like the top level —
308
+ * a junk field degrades, a row without the route's stable `entryId` key
309
+ * drops whole. */
310
+ function deriveClientServerView(value: unknown): ClientServerView | undefined {
311
+ const record = asRecord(value)
312
+ const entryId = asStringSet(record.entryId)
313
+ if (entryId === '') return undefined
314
+ return {
315
+ entryId,
316
+ serverId: asStringSet(record.serverId),
317
+ serverUrl: asStringSet(record.serverUrl),
318
+ serverName: asStringSet(record.serverName),
319
+ displayName: asStringSet(record.displayName),
320
+ state: clientStateOf(record.state),
321
+ nextRetryAt: finiteOrUndefined(record.nextRetryAt),
322
+ lastError: asStringSet(record.lastError),
323
+ compat: deriveCompatView(record.compat),
324
+ pluginBridge: derivePluginBridgeStatuses(record.pluginBridge),
325
+ }
326
+ }
327
+
243
328
  /** At most `cap` strings out of an array-shaped value; anything else is none. */
244
329
  function stringListOf(value: unknown, cap: number): string[] {
245
330
  if (!Array.isArray(value)) return []
@@ -325,18 +410,20 @@ export function deriveClientStatusView(body: ClientStatusBody): ClientConnection
325
410
  // diagnostics below it showed live relay calls. `offline` rides the same
326
411
  // mapping to the probe word for it; anything else unknown still degrades
327
412
  // to `unpaired`.
328
- const rawState = typeof safe.state === 'string' ? safe.state : ''
329
- const state = CLIENT_STATES.includes(rawState as ClientConnectionView['state'])
330
- ? rawState as ClientConnectionView['state']
331
- : WIRE_STATE_ALIASES[rawState] ?? 'unpaired'
332
- const nextRetryAt = typeof safe.nextRetryAt === 'number' && Number.isFinite(safe.nextRetryAt)
333
- ? safe.nextRetryAt
334
- : undefined
413
+ const state = clientStateOf(safe.state)
414
+ // MS-6: the pool list rides as `servers[]` (one row per paired entry); a
415
+ // legacy single-server body answers the empty list and the pre-MS-6 page
416
+ // shape otherwise.
417
+ const servers = (Array.isArray(safe.servers) ? safe.servers : [])
418
+ .map(deriveClientServerView)
419
+ .filter((row): row is ClientServerView => row !== undefined)
420
+ const maxServersRaw = finiteOrUndefined(safe.maxServers)
421
+ const maxServers = maxServersRaw !== undefined && maxServersRaw >= 1 ? maxServersRaw : MAX_PAIRED_SERVERS
335
422
  return {
336
423
  state,
337
424
  serverUrl: typeof safe.serverUrl === 'string' ? safe.serverUrl : '',
338
425
  serverName: typeof safe.serverName === 'string' ? safe.serverName : '',
339
- nextRetryAt,
426
+ nextRetryAt: finiteOrUndefined(safe.nextRetryAt),
340
427
  lastError: typeof safe.lastError === 'string' ? safe.lastError : '',
341
428
  deviceName: typeof safe.deviceName === 'string' ? safe.deviceName : '',
342
429
  intercept: deriveInterceptView(safe.intercept),
@@ -346,6 +433,9 @@ export function deriveClientStatusView(body: ClientStatusBody): ClientConnection
346
433
  uploadProbe: safe.serviceProbe !== null && typeof safe.serviceProbe === 'object'
347
434
  ? Object.freeze({ ...(safe.serviceProbe as Record<string, unknown>) })
348
435
  : undefined,
436
+ servers,
437
+ maxServers,
438
+ limitReached: servers.length >= maxServers,
349
439
  }
350
440
  }
351
441
 
@@ -399,6 +489,11 @@ export interface ClaimRouteBody {
399
489
  token?: unknown
400
490
  deviceId?: unknown
401
491
  deviceName?: unknown
492
+ /** MS-8: the pool entry the pairing landed in (pool-mode answers carry it;
493
+ * the legacy single-server answer carries `token` instead and no
494
+ * `entryId` — the token never reaches the browser in pool mode). The
495
+ * wizard records it as the step-3 selected server. */
496
+ entryId?: unknown
402
497
  /** The normalized address the backend validated — what the form writes. */
403
498
  serverUrl?: unknown
404
499
  code?: string
@@ -421,6 +516,61 @@ export function normalizePairingCode(input: string): string {
421
516
  .slice(0, PAIRING_CODE_LENGTH)
422
517
  }
423
518
 
519
+ /**
520
+ * The address comparison the overwrite judgment makes (MS-6): trim padding,
521
+ * drop trailing slashes. Light on purpose — the backend re-normalizes the
522
+ * address authoritatively on claim; here it only decides whether the typed
523
+ * address NAMES an already-paired server, and `http://x.local:3088/` must
524
+ * count as that server while `http://x.local:3089` must not.
525
+ */
526
+ function normalizedPairAddress(url: string): string {
527
+ return url.trim().replace(/\/+$/u, '')
528
+ }
529
+
530
+ /** What {@link canSubmitClientPairing} decides. */
531
+ export interface CanSubmitClientPairingVerdict {
532
+ /** The 配对 button may act: everything locally checkable passes. */
533
+ allowed: boolean
534
+ /** The typed address names an already-paired server — the claim will
535
+ * UPDATE that entry's token in place, not add a ninth one. */
536
+ isOverwrite: boolean
537
+ /** A NEW address while the pool is full (`servers.length >= maxServers`):
538
+ * the claim must not even leave the page (the route would answer
539
+ * `limit-reached`). An overwrite never sets this. */
540
+ blockedByLimit: boolean
541
+ }
542
+
543
+ /**
544
+ * The 配对 button's whole gate, as one pure truth table (MS-6): the claim
545
+ * needs an address and a COMPLETE 8-character pairing code (the input box
546
+ * normalizes as the user types; this judge is the same bar the route's code
547
+ * grammar enforces), a quiet page (`busy`), and a valid device-name draft
548
+ * (`deviceNameInvalid`, T60 — a red-boxed name would silently pair under the
549
+ * fallback copy). The pool cap blocks only NEW addresses: a full pool still
550
+ * accepts re-pairing an existing one in place, which is how a user whose
551
+ * token was revoked at 8/8 gets back in.
552
+ */
553
+ export function canSubmitClientPairing(input: {
554
+ pairUrl: string
555
+ pairCode: string
556
+ servers: readonly ClientServerView[]
557
+ maxServers?: number
558
+ busy?: boolean
559
+ deviceNameInvalid?: boolean
560
+ }): CanSubmitClientPairingVerdict {
561
+ const maxServers = input.maxServers ?? MAX_PAIRED_SERVERS
562
+ const address = normalizedPairAddress(input.pairUrl)
563
+ const isOverwrite = address !== ''
564
+ && input.servers.some((server) => normalizedPairAddress(server.serverUrl) === address)
565
+ const blockedByLimit = !isOverwrite && input.servers.length >= maxServers
566
+ const allowed = address !== ''
567
+ && normalizePairingCode(input.pairCode).length === PAIRING_CODE_LENGTH
568
+ && !blockedByLimit
569
+ && input.busy !== true
570
+ && input.deviceNameInvalid !== true
571
+ return { allowed, isOverwrite, blockedByLimit }
572
+ }
573
+
424
574
  /**
425
575
  * Whether the settings page may follow the SERVER's record of this device's
426
576
  * name into the row (T59): the body carried a name, it differs from the row
@@ -803,6 +953,8 @@ export type SettingsFieldName =
803
953
  | 'pushSummary'
804
954
  | 'pushTurnEnd'
805
955
  | 'pushTool'
956
+ | 'pushEnabled'
957
+ | 'pushSessionName'
806
958
  | 'pushDebounceMs'
807
959
  | 'lang'
808
960
  | 'serverName'
@@ -824,6 +976,8 @@ export const SETTINGS_FIELDS: readonly SettingsFieldSpec[] = [
824
976
  textField('trustedProxies'),
825
977
  intField('rateLimit', 1, Number.MAX_SAFE_INTEGER),
826
978
  textField('vapidSubject'),
979
+ boolField('pushEnabled'),
980
+ boolField('pushSessionName'),
827
981
  boolField('pushSummary'),
828
982
  boolField('pushTurnEnd'),
829
983
  boolField('pushTool'),
@@ -1042,6 +1196,8 @@ export interface ZenRemoteFormState extends SettingsFormShellState {
1042
1196
  pushSummary: SettingsFieldState
1043
1197
  pushTurnEnd: SettingsFieldState
1044
1198
  pushTool: SettingsFieldState
1199
+ pushEnabled: SettingsFieldState
1200
+ pushSessionName: SettingsFieldState
1045
1201
  pushDebounceMs: SettingsFieldState
1046
1202
  lang: SettingsFieldState
1047
1203
  serverName: SettingsFieldState