dsh-zen-remote 1.1.16 → 2.0.0

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 (255) hide show
  1. package/AGENTS.md +54 -9
  2. package/README.md +249 -190
  3. package/README.zh-CN.md +247 -201
  4. package/assets/home.png +0 -0
  5. package/assets/info.png +0 -0
  6. package/assets/pairing.png +0 -0
  7. package/assets/session.png +0 -0
  8. package/assets/sheet.png +0 -0
  9. package/cordis.patch.yml +12 -11
  10. package/cordis.patch.yml.example +24 -9
  11. package/docs/interface.md +57 -2
  12. package/docs/remote-access.en.md +99 -58
  13. package/docs/remote-access.md +99 -42
  14. package/dsh-push.mjs +67 -54
  15. package/lan-gate.mjs +88 -20
  16. package/lib/activity.js +168 -0
  17. package/lib/activity.js.map +1 -0
  18. package/lib/admin-routes.js +526 -0
  19. package/lib/admin-routes.js.map +1 -0
  20. package/lib/client-pairing.js +224 -0
  21. package/lib/client-pairing.js.map +1 -0
  22. package/lib/client-routes.js +674 -0
  23. package/lib/client-routes.js.map +1 -0
  24. package/lib/client.js +10051 -4777
  25. package/lib/config.js +346 -0
  26. package/lib/config.js.map +1 -0
  27. package/lib/fetch-route-intercept.js +418 -0
  28. package/lib/fetch-route-intercept.js.map +1 -0
  29. package/lib/fingerprint.js +363 -0
  30. package/lib/fingerprint.js.map +1 -0
  31. package/lib/http.js +69 -0
  32. package/lib/http.js.map +1 -0
  33. package/lib/index.js +675 -54
  34. package/lib/index.js.map +1 -1
  35. package/lib/intercept-shape.js +81 -0
  36. package/lib/intercept-shape.js.map +1 -0
  37. package/lib/intercept.js +3040 -0
  38. package/lib/intercept.js.map +1 -0
  39. package/lib/lan-gate-server.cjs +532 -121
  40. package/lib/merge-streams.js +1165 -0
  41. package/lib/merge-streams.js.map +1 -0
  42. package/lib/relay-access.js +496 -0
  43. package/lib/relay-access.js.map +1 -0
  44. package/lib/relay-client.js +1303 -0
  45. package/lib/relay-client.js.map +1 -0
  46. package/lib/relay-filter.js +282 -0
  47. package/lib/relay-filter.js.map +1 -0
  48. package/lib/relay-server.js +1792 -0
  49. package/lib/relay-server.js.map +1 -0
  50. package/lib/restart-fields.js +34 -0
  51. package/lib/restart-fields.js.map +1 -0
  52. package/lib/restart-watcher.js +106 -0
  53. package/lib/restart-watcher.js.map +1 -0
  54. package/lib/session-reference.js +157 -0
  55. package/lib/session-reference.js.map +1 -0
  56. package/lib/share-export.js +16 -1
  57. package/lib/share-export.js.map +1 -1
  58. package/lib/share-ops.js +86 -0
  59. package/lib/share-ops.js.map +1 -0
  60. package/lib/share-store.js +294 -0
  61. package/lib/share-store.js.map +1 -0
  62. package/lib/types/activity.d.ts +99 -0
  63. package/lib/types/activity.d.ts.map +1 -0
  64. package/lib/types/admin-routes.d.ts +129 -0
  65. package/lib/types/admin-routes.d.ts.map +1 -0
  66. package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
  67. package/lib/types/client/MobileHome.d.ts +1 -1
  68. package/lib/types/client/MobileHome.d.ts.map +1 -1
  69. package/lib/types/client/MobileHomeChips.d.ts +1 -1
  70. package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
  71. package/lib/types/client/MobileSessionHeader.d.ts +13 -2
  72. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  73. package/lib/types/client/MobileSessionInfo.d.ts +20 -3
  74. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  75. package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
  76. package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
  77. package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
  78. package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
  79. package/lib/types/client/RemoteShareMenu.d.ts +28 -0
  80. package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
  81. package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
  82. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
  83. package/lib/types/client/compat/desktop.d.ts +21 -0
  84. package/lib/types/client/compat/desktop.d.ts.map +1 -0
  85. package/lib/types/client/compat/types.d.ts +57 -0
  86. package/lib/types/client/compat/types.d.ts.map +1 -1
  87. package/lib/types/client/current-session-report.d.ts +37 -0
  88. package/lib/types/client/current-session-report.d.ts.map +1 -0
  89. package/lib/types/client/effects/header-status.d.ts.map +1 -1
  90. package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
  91. package/lib/types/client/effects/permission-sheet.d.ts +39 -0
  92. package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
  93. package/lib/types/client/effects/remote-session.d.ts +25 -0
  94. package/lib/types/client/effects/remote-session.d.ts.map +1 -0
  95. package/lib/types/client/effects/turn-fold.d.ts +9 -0
  96. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  97. package/lib/types/client/index.d.ts.map +1 -1
  98. package/lib/types/client/locales.d.ts +187 -2
  99. package/lib/types/client/locales.d.ts.map +1 -1
  100. package/lib/types/client/remote-fetch.d.ts +51 -0
  101. package/lib/types/client/remote-fetch.d.ts.map +1 -0
  102. package/lib/types/client/remote-group-expansion.d.ts +41 -0
  103. package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
  104. package/lib/types/client/remote-share-css.d.ts +19 -0
  105. package/lib/types/client/remote-share-css.d.ts.map +1 -0
  106. package/lib/types/client/remote-share-register.d.ts +113 -0
  107. package/lib/types/client/remote-share-register.d.ts.map +1 -0
  108. package/lib/types/client/remote-status-css.d.ts +15 -0
  109. package/lib/types/client/remote-status-css.d.ts.map +1 -0
  110. package/lib/types/client/remote-status-register.d.ts +46 -0
  111. package/lib/types/client/remote-status-register.d.ts.map +1 -0
  112. package/lib/types/client/settings/SettingsSection.d.ts +46 -0
  113. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
  114. package/lib/types/client/settings/register-settings.d.ts +58 -0
  115. package/lib/types/client/settings/register-settings.d.ts.map +1 -0
  116. package/lib/types/client/settings/settings-css.d.ts +10 -0
  117. package/lib/types/client/settings/settings-css.d.ts.map +1 -0
  118. package/lib/types/client/share/share-preview.d.ts.map +1 -1
  119. package/lib/types/client/styles/composer.css.d.ts +1 -1
  120. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  121. package/lib/types/client/styles/header.css.d.ts +1 -1
  122. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  123. package/lib/types/client/styles/info.css.d.ts +1 -1
  124. package/lib/types/client/styles/info.css.d.ts.map +1 -1
  125. package/lib/types/client/styles/remote-session.css.d.ts +2 -0
  126. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
  127. package/lib/types/client-data/current-session.d.ts +41 -0
  128. package/lib/types/client-data/current-session.d.ts.map +1 -0
  129. package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
  130. package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
  131. package/lib/types/client-data/remote-status.d.ts +148 -0
  132. package/lib/types/client-data/remote-status.d.ts.map +1 -0
  133. package/lib/types/client-data/settings-form.d.ts +774 -0
  134. package/lib/types/client-data/settings-form.d.ts.map +1 -0
  135. package/lib/types/client-data/shares.d.ts +211 -0
  136. package/lib/types/client-data/shares.d.ts.map +1 -0
  137. package/lib/types/client-pairing.d.ts +114 -0
  138. package/lib/types/client-pairing.d.ts.map +1 -0
  139. package/lib/types/client-routes.d.ts +112 -0
  140. package/lib/types/client-routes.d.ts.map +1 -0
  141. package/lib/types/config.d.ts +200 -0
  142. package/lib/types/config.d.ts.map +1 -0
  143. package/lib/types/fetch-route-intercept.d.ts +156 -0
  144. package/lib/types/fetch-route-intercept.d.ts.map +1 -0
  145. package/lib/types/fingerprint.d.ts +164 -0
  146. package/lib/types/fingerprint.d.ts.map +1 -0
  147. package/lib/types/http.d.ts +41 -0
  148. package/lib/types/http.d.ts.map +1 -0
  149. package/lib/types/index.d.ts +56 -24
  150. package/lib/types/index.d.ts.map +1 -1
  151. package/lib/types/intercept-shape.d.ts +32 -0
  152. package/lib/types/intercept-shape.d.ts.map +1 -0
  153. package/lib/types/intercept.d.ts +432 -0
  154. package/lib/types/intercept.d.ts.map +1 -0
  155. package/lib/types/merge-streams.d.ts +393 -0
  156. package/lib/types/merge-streams.d.ts.map +1 -0
  157. package/lib/types/relay-access.d.ts +310 -0
  158. package/lib/types/relay-access.d.ts.map +1 -0
  159. package/lib/types/relay-client.d.ts +274 -0
  160. package/lib/types/relay-client.d.ts.map +1 -0
  161. package/lib/types/relay-filter.d.ts +103 -0
  162. package/lib/types/relay-filter.d.ts.map +1 -0
  163. package/lib/types/relay-server.d.ts +208 -0
  164. package/lib/types/relay-server.d.ts.map +1 -0
  165. package/lib/types/restart-fields.d.ts +22 -0
  166. package/lib/types/restart-fields.d.ts.map +1 -0
  167. package/lib/types/restart-watcher.d.ts +83 -0
  168. package/lib/types/restart-watcher.d.ts.map +1 -0
  169. package/lib/types/session-reference.d.ts +66 -0
  170. package/lib/types/session-reference.d.ts.map +1 -0
  171. package/lib/types/share-export.d.ts +14 -1
  172. package/lib/types/share-export.d.ts.map +1 -1
  173. package/lib/types/share-ops.d.ts +79 -0
  174. package/lib/types/share-ops.d.ts.map +1 -0
  175. package/lib/types/share-store.d.ts +87 -0
  176. package/lib/types/share-store.d.ts.map +1 -0
  177. package/lib/types/virtual-id.d.ts +38 -0
  178. package/lib/types/virtual-id.d.ts.map +1 -0
  179. package/lib/virtual-id.js +52 -0
  180. package/lib/virtual-id.js.map +1 -0
  181. package/llms.txt +17 -14
  182. package/package.json +60 -50
  183. package/scripts/build-client.mjs +5 -3
  184. package/scripts/check-host-shadow.mjs +19 -2
  185. package/scripts/check-settings-form.mjs +933 -0
  186. package/scripts/check-shares.mjs +397 -0
  187. package/scripts/check-upload-endpoint.mjs +59 -3
  188. package/scripts/sync-doc-version.mjs +8 -15
  189. package/scripts/verify-fingerprint-live.mjs +111 -0
  190. package/src/activity.ts +218 -0
  191. package/src/admin-routes.ts +627 -0
  192. package/src/client/MobileAttachButton.tsx +2 -2
  193. package/src/client/MobileAttachChips.tsx +3 -3
  194. package/src/client/MobileDrawerFooter.tsx +7 -4
  195. package/src/client/MobileHome.tsx +20 -16
  196. package/src/client/MobileHomeChips.tsx +16 -16
  197. package/src/client/MobileNavOverlay.tsx +2 -2
  198. package/src/client/MobileNavToggle.tsx +3 -3
  199. package/src/client/MobileSessionHeader.tsx +63 -42
  200. package/src/client/MobileSessionInfo.tsx +118 -17
  201. package/src/client/RemoteComposerBanner.tsx +76 -0
  202. package/src/client/RemoteHeaderIcon.tsx +103 -0
  203. package/src/client/RemoteShareMenu.tsx +57 -0
  204. package/src/client/RemoteStatusIcon.tsx +91 -0
  205. package/src/client/compat/desktop.ts +22 -0
  206. package/src/client/compat/types.ts +62 -0
  207. package/src/client/current-session-report.ts +164 -0
  208. package/src/client/effects/header-status.ts +21 -17
  209. package/src/client/effects/keyboard-guard.ts +65 -1
  210. package/src/client/effects/permission-sheet.ts +149 -0
  211. package/src/client/effects/remote-session.ts +68 -0
  212. package/src/client/effects/turn-fold.ts +52 -0
  213. package/src/client/index.tsx +151 -36
  214. package/src/client/locales.ts +396 -2
  215. package/src/client/remote-fetch.ts +125 -0
  216. package/src/client/remote-group-expansion.ts +190 -0
  217. package/src/client/remote-share-css.ts +54 -0
  218. package/src/client/remote-share-register.ts +244 -0
  219. package/src/client/remote-status-css.ts +64 -0
  220. package/src/client/remote-status-register.ts +107 -0
  221. package/src/client/settings/SettingsSection.tsx +1229 -0
  222. package/src/client/settings/register-settings.ts +116 -0
  223. package/src/client/settings/settings-css.ts +236 -0
  224. package/src/client/share/share-preview.tsx +6 -3
  225. package/src/client/share/share-sheet.tsx +3 -3
  226. package/src/client/styles/composer.css.ts +119 -26
  227. package/src/client/styles/header.css.ts +7 -7
  228. package/src/client/styles/info.css.ts +74 -0
  229. package/src/client/styles/remote-session.css.ts +251 -0
  230. package/src/client-data/current-session.ts +57 -0
  231. package/src/client-data/remote-group-expansion.ts +145 -0
  232. package/src/client-data/remote-status.ts +374 -0
  233. package/src/client-data/settings-form.ts +1371 -0
  234. package/src/client-data/shares.ts +467 -0
  235. package/src/client-pairing.ts +244 -0
  236. package/src/client-routes.ts +732 -0
  237. package/src/config.ts +447 -0
  238. package/src/fetch-route-intercept.ts +510 -0
  239. package/src/fingerprint.ts +408 -0
  240. package/src/http.ts +68 -0
  241. package/src/index.ts +719 -62
  242. package/src/intercept-shape.ts +87 -0
  243. package/src/intercept.ts +3362 -0
  244. package/src/merge-streams.ts +1272 -0
  245. package/src/relay-access.ts +669 -0
  246. package/src/relay-client.ts +1514 -0
  247. package/src/relay-filter.ts +291 -0
  248. package/src/relay-server.ts +2025 -0
  249. package/src/restart-fields.ts +36 -0
  250. package/src/restart-watcher.ts +140 -0
  251. package/src/session-reference.ts +156 -0
  252. package/src/share-export.ts +26 -1
  253. package/src/share-ops.ts +114 -0
  254. package/src/share-store.ts +373 -0
  255. package/src/virtual-id.ts +59 -0
@@ -0,0 +1,1371 @@
1
+ /**
2
+ * Staged configuration form for the `dsh-zen-remote` plugin row, in the same
3
+ * shape the dsh-llm-verifier page uses (a control stages what the user types,
4
+ * one save writes every staged edit as a single revision-fenced path mutation
5
+ * through the shared `configForms` form). Browser-import-free so tests drive
6
+ * it directly:
7
+ *
8
+ * - `deriveSettingsView(status)` maps the `GET /_dsh/zen-remote/admin/status`
9
+ * body (T14's same-origin route wrapping the gateway's local admin API) into
10
+ * what the settings block renders: per-field effective value + source layer,
11
+ * the device list, the live pairing code with its remaining seconds, and the
12
+ * `viaGateway` flag that disables every server-local operation.
13
+ * - `deriveClientStatusView(status)` maps the `GET /_dsh/zen-remote/client/status`
14
+ * body (T16's sub-client probe) into the client group's connection line.
15
+ * - `ZenRemoteSettingsForm` stages the row-layer field edits and saves them as
16
+ * one `mutate(ops, expectedRevision)` call, and owns the two DIRECT writes
17
+ * of the client group (pairing write / token clear) plus the device token's
18
+ * configured flag read from the describe view's secrets sidecar. Since T17
19
+ * it also carries the page's two-level role decision — the saved row role
20
+ * first, the client-config probe as fallback — and the `restartPending`
21
+ * flag behind the "saving reloads the plugin" note.
22
+ *
23
+ * The wire contract lives here because the host half that serves it is a
24
+ * separate task; the shapes mirror `lib/lan-gate-server.cjs`'s status payload
25
+ * (devices / pairing) plus the T14 envelope (ok / gateway / config / viaGateway).
26
+ */
27
+
28
+ import { RESTART_FIELDS } from '../restart-fields.ts'
29
+
30
+ // --- wire shapes -----------------------------------------------------------
31
+
32
+ /** Where a resolved field value came from (mirror of src/config.ts's union; the client half cannot import the host module). */
33
+ export type ConfigSource = 'env' | 'row' | 'file' | 'default'
34
+
35
+ /** One paired gateway device, as the gateway's status payload lists it. */
36
+ export interface AdminDevice {
37
+ id: string
38
+ name: string
39
+ role: 'web' | 'desktop-client'
40
+ kind: 'auto' | 'phone' | 'desktop'
41
+ createdAt: number
42
+ lastSeen: number
43
+ ua?: string
44
+ hasPush: boolean
45
+ }
46
+
47
+ /** The one currently-live pairing code, if any. */
48
+ export interface AdminPairing {
49
+ code: string
50
+ expiresAt: number
51
+ role: 'web' | 'desktop-client'
52
+ }
53
+
54
+ /**
55
+ * The gateway's `/lan-gate/status` reply, relayed verbatim inside
56
+ * `admin/status`'s `gateway` field (see lib/lan-gate-server.cjs's
57
+ * statusHandler — this mirror keeps only what the settings block reads).
58
+ */
59
+ export interface GatewayStatus {
60
+ state?: string
61
+ port?: number
62
+ target?: string
63
+ devices?: AdminDevice[]
64
+ pairing?: AdminPairing | null
65
+ pushSubscriptions?: number
66
+ }
67
+
68
+ /**
69
+ * The `GET /_dsh/zen-remote/admin/status` body, exactly as src/admin-routes.ts
70
+ * answers it: the gateway payload lives NESTED under `gateway` (null when the
71
+ * gateway did not answer healthily) and `gatewayStatus` is the HTTP status the
72
+ * route saw (`null` when there was no answer at all).
73
+ */
74
+ export interface AdminStatusBody {
75
+ ok?: boolean
76
+ gateway?: GatewayStatus | null
77
+ gatewayReachable?: boolean
78
+ gatewayStatus?: number | null
79
+ config?: {
80
+ values?: Record<string, unknown>
81
+ sources?: Record<string, string>
82
+ }
83
+ viaGateway?: boolean
84
+ }
85
+
86
+ /** Same-origin admin routes the settings block talks to (host half: T14). */
87
+ export const ADMIN_STATUS_ROUTE = '/_dsh/zen-remote/admin/status'
88
+ export const ADMIN_PAIR_ROUTE = '/_dsh/zen-remote/admin/pair'
89
+ export const ADMIN_ACTION_ROUTE = '/_dsh/zen-remote/admin/action'
90
+ export const ADMIN_PUSH_TEST_ROUTE = '/_dsh/zen-remote/admin/push-test'
91
+
92
+ /** Same-origin client routes the sub-client block talks to (host half: T16). */
93
+ export const CLIENT_CLAIM_ROUTE = '/_dsh/zen-remote/client/claim'
94
+ export const CLIENT_STATUS_ROUTE = '/_dsh/zen-remote/client/status'
95
+ /** T43: one immediate reconnect — answered 409 unless the client is offline. */
96
+ export const CLIENT_RECONNECT_ROUTE = '/_dsh/zen-remote/client/reconnect'
97
+
98
+ /** The lightweight client-facing config route (host half, both roles): the
99
+ * settings page's FALLBACK role probe (T17) — it registers wherever a
100
+ * webServer exists, and since T17 its body carries the effective `role`
101
+ * (resolveConfig's merged value, nothing sensitive). */
102
+ export const CLIENT_CONFIG_ROUTE = '/_dsh/mobile-nav/client-config'
103
+
104
+ /** The body of `GET /_dsh/mobile-nav/client-config` — the interface-half knobs
105
+ * the client bundle reads, plus (T17) the effective role the settings page
106
+ * falls back to. */
107
+ export interface ClientConfigBody {
108
+ role?: unknown
109
+ turnFoldDesktop?: boolean
110
+ keyboardLiftRatio?: number
111
+ keyboardLiftMaxPx?: number
112
+ keyboardSafetyPadPx?: number
113
+ }
114
+
115
+ /** Field name of the row secret the pairing flow writes (never echoed back
116
+ * anywhere; the describe view's secrets sidecar is the only "is it set"). */
117
+ export const DEVICE_TOKEN_FIELD = 'deviceToken'
118
+
119
+ // --- client-half wire shapes -------------------------------------------------
120
+
121
+ /**
122
+ * The `GET /_dsh/zen-remote/client/status` body, exactly as
123
+ * src/client-routes.ts answers it: `serverUrl` is present only once a token
124
+ * exists (the unpaired answer is `{ state: 'unpaired' }` alone). The token
125
+ * itself never rides any status response. The T43 diagnostics fields ride
126
+ * only on a failed live connect of a wired relay client; `intercept` (T23b)
127
+ * and `compat` (T42) are rendered only when the body carries them — earlier
128
+ * servers answer neither.
129
+ */
130
+ export interface ClientStatusBody {
131
+ /** The probe vocabulary (`connected` / `unreachable` / `revoked` /
132
+ * `unexpected`), the row's two own answers (`unpaired` / `invalid-url`),
133
+ * the relay verdicts (`revoked` / `incompatible`) — and, since the
134
+ * relay-wired route reports the relay client's own state verbatim, the
135
+ * `online` / `offline` words too ({@link deriveClientStatusView} maps
136
+ * them; T55). */
137
+ state?: string
138
+ serverUrl?: string
139
+ serverName?: unknown
140
+ /** Epoch ms of the relay client's next automatic reconnect (offline only). */
141
+ nextRetryAt?: unknown
142
+ /** The error code of the last failure — a code, never a message or URL. */
143
+ lastError?: unknown
144
+ /**
145
+ * The SERVER's record of THIS device's name (T59, presence-gated like the
146
+ * diagnostics fields — older servers answer without it). The settings page
147
+ * follows it into the row while the user is not editing the field.
148
+ */
149
+ deviceName?: unknown
150
+ /** T23b request-interceptor diagnostics (provisional shape; presence-gated). */
151
+ intercept?: unknown
152
+ /** T42 relay compat diagnostics: `{ identical: string[], different:
153
+ * string[], unavailable: string[], incompatibleCalls: { time: number,
154
+ * endpoint: string, code: string }[] }` (presence-gated). */
155
+ compat?: unknown
156
+ }
157
+
158
+ /** One remote-call failure line in the diagnostics lists. `time` is epoch
159
+ * milliseconds — ISO-stamped rings are parsed at derive time; `method`
160
+ * carries the wire's method-or-endpoint name. */
161
+ export interface ClientDiagFailureView {
162
+ time: number
163
+ method: string
164
+ code: string
165
+ }
166
+
167
+ /**
168
+ * T23b interceptor diagnostics as the block renders them. The wire field is
169
+ * presence-gated: `undefined` here means the body carried none (an older
170
+ * server), and the whole interceptor group stays hidden.
171
+ */
172
+ export interface ClientInterceptView {
173
+ installed: boolean
174
+ /** Shape-detection failure reasons — non-empty means remote features are off. */
175
+ reasons: string[]
176
+ /** The most recent remote call failures (at most 10). */
177
+ recentFailures: ClientDiagFailureView[]
178
+ }
179
+
180
+ /** T42 compat diagnostics as the block renders them (presence-gated like {@link ClientInterceptView}). */
181
+ export interface ClientCompatView {
182
+ /** Names of the groups whose fingerprints differ. */
183
+ mismatchedGroups: string[]
184
+ /** The most recent incompatible calls (at most 10). */
185
+ recentCalls: ClientDiagFailureView[]
186
+ }
187
+
188
+ /** One client connection as the block renders it. */
189
+ export interface ClientConnectionView {
190
+ state: 'unpaired' | 'connected' | 'revoked' | 'unreachable' | 'unexpected' | 'invalid-url' | 'incompatible'
191
+ serverUrl: string
192
+ serverName: string
193
+ /** Verbatim from the body when it carried a finite number; the countdown
194
+ * math happens at render time against the live clock. */
195
+ nextRetryAt: number | undefined
196
+ /** The last failure's code, '' when none is reported. */
197
+ lastError: string
198
+ /** The SERVER's record of this device's name (T59): '' when the body did
199
+ * not carry one — empty means "nothing to follow". */
200
+ deviceName: string
201
+ intercept: ClientInterceptView | undefined
202
+ compat: ClientCompatView | undefined
203
+ }
204
+
205
+ const CLIENT_STATES: readonly ClientConnectionView['state'][] = ['unpaired', 'connected', 'revoked', 'unreachable', 'unexpected', 'invalid-url', 'incompatible']
206
+
207
+ /** Wire `state` words the status route can answer that are NOT the view
208
+ * vocabulary (T55): the relay client's `online` is the page's `connected`,
209
+ * and its `offline` is the probe vocabulary's `unreachable` — the offline
210
+ * line, whose countdown the body's `nextRetryAt` backs when present. */
211
+ const WIRE_STATE_ALIASES: Readonly<Record<string, ClientConnectionView['state']>> = {
212
+ online: 'connected',
213
+ offline: 'unreachable',
214
+ }
215
+
216
+ /** At most `cap` strings out of an array-shaped value; anything else is none. */
217
+ function stringListOf(value: unknown, cap: number): string[] {
218
+ if (!Array.isArray(value)) return []
219
+ return value.filter((entry): entry is string => typeof entry === 'string').slice(0, cap)
220
+ }
221
+
222
+ /**
223
+ * The MOST RECENT 10 diagnostics failure rows out of an array-shaped value
224
+ * (the rings are oldest-first, so the tail is the fresh end). Both wire
225
+ * dialects render: the T42 compat ring stamps epoch-millisecond numbers
226
+ * under `endpoint`, the T23b interceptor ring ISO strings under the same
227
+ * key — times parse leniently either way, and an unparseable value renders
228
+ * as 0 rather than dropping the row.
229
+ */
230
+ function failureListOf(value: unknown): ClientDiagFailureView[] {
231
+ if (!Array.isArray(value)) return []
232
+ const rows: ClientDiagFailureView[] = []
233
+ for (const entry of value.slice(-10)) {
234
+ const record = asRecord(entry)
235
+ let time = 0
236
+ if (typeof record.time === 'number' && Number.isFinite(record.time)) time = record.time
237
+ else if (typeof record.time === 'string') {
238
+ const parsed = Date.parse(record.time)
239
+ if (Number.isFinite(parsed)) time = parsed
240
+ }
241
+ rows.push({
242
+ time,
243
+ method: asStringSet(record.method ?? record.endpoint),
244
+ code: asStringSet(record.code),
245
+ })
246
+ }
247
+ return rows
248
+ }
249
+
250
+ /** T23b interceptor block, only when the body carried the field at all. */
251
+ function deriveInterceptView(value: unknown): ClientInterceptView | undefined {
252
+ if (value === undefined || value === null) return undefined
253
+ const record = asRecord(value)
254
+ return {
255
+ installed: record.installed === true,
256
+ reasons: stringListOf(record.reasons, 20),
257
+ recentFailures: failureListOf(record.recentFailures),
258
+ }
259
+ }
260
+
261
+ /** T42 compat block, only when the body carried the field at all. The wire
262
+ * names are the status route's (`different` / `incompatibleCalls`); the view
263
+ * keeps the render-side names the block was written against. */
264
+ function deriveCompatView(value: unknown): ClientCompatView | undefined {
265
+ if (value === undefined || value === null) return undefined
266
+ const record = asRecord(value)
267
+ return {
268
+ mismatchedGroups: stringListOf(record.different, 20),
269
+ recentCalls: failureListOf(record.incompatibleCalls),
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Map one `client/status` body into the view the client group renders.
275
+ * Tolerant like {@link deriveSettingsView}: an unexpected shape degrades to
276
+ * "unpaired" instead of throwing into the plugin page.
277
+ */
278
+ export function deriveClientStatusView(body: ClientStatusBody): ClientConnectionView {
279
+ const safe = body !== null && typeof body === 'object' ? body : {}
280
+ // T55: the route answers TWO vocabularies. The probe fallback speaks the
281
+ // view's own words (connected / unreachable / …, ProbeState), but the
282
+ // relay-wired route reports the RELAY client's state — `online` verbatim
283
+ // (client-routes.ts sends `state: relay.state` on the cached-verdict and
284
+ // fresh-connect paths) — which the view never listed, so a PAIRED, CONNECTED
285
+ // client fell through the unknown-state bucket into "未配对" while the
286
+ // diagnostics below it showed live relay calls. `offline` rides the same
287
+ // mapping to the probe word for it; anything else unknown still degrades
288
+ // to `unpaired`.
289
+ const rawState = typeof safe.state === 'string' ? safe.state : ''
290
+ const state = CLIENT_STATES.includes(rawState as ClientConnectionView['state'])
291
+ ? rawState as ClientConnectionView['state']
292
+ : WIRE_STATE_ALIASES[rawState] ?? 'unpaired'
293
+ const nextRetryAt = typeof safe.nextRetryAt === 'number' && Number.isFinite(safe.nextRetryAt)
294
+ ? safe.nextRetryAt
295
+ : undefined
296
+ return {
297
+ state,
298
+ serverUrl: typeof safe.serverUrl === 'string' ? safe.serverUrl : '',
299
+ serverName: typeof safe.serverName === 'string' ? safe.serverName : '',
300
+ nextRetryAt,
301
+ lastError: typeof safe.lastError === 'string' ? safe.lastError : '',
302
+ deviceName: typeof safe.deviceName === 'string' ? safe.deviceName : '',
303
+ intercept: deriveInterceptView(safe.intercept),
304
+ compat: deriveCompatView(safe.compat),
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Which connection line the block renders (T43 diagnostics wording, chosen
310
+ * as a pure descriptor so the copy table and the countdown math stay
311
+ * testable without a browser): connected prefers the handshake's server
312
+ * name; any verdict the body backs with a `nextRetryAt` is OFFLINE first —
313
+ * the relay client is mid-retry and the line counts down to it, whether the
314
+ * probe classified the failure `unreachable` or `unexpected` (T43-fix);
315
+ * without a `nextRetryAt` the two map one plain copy each. The rest map one
316
+ * state each.
317
+ */
318
+ export type ClientStatusLine =
319
+ | { kind: 'connectedName', serverName: string }
320
+ | { kind: 'connected', serverUrl: string }
321
+ | { kind: 'offlineRetry', seconds: number }
322
+ | { kind: 'offlineRetrySoon' }
323
+ | { kind: 'unreachable' }
324
+ | { kind: 'revoked' }
325
+ | { kind: 'incompatible' }
326
+ | { kind: 'unexpected' }
327
+ | { kind: 'invalidUrl' }
328
+ | { kind: 'unpaired' }
329
+
330
+ export function clientStatusLineOf(view: ClientConnectionView, now: number): ClientStatusLine {
331
+ switch (view.state) {
332
+ case 'connected':
333
+ return view.serverName !== ''
334
+ ? { kind: 'connectedName', serverName: view.serverName }
335
+ : { kind: 'connected', serverUrl: view.serverUrl }
336
+ case 'unreachable':
337
+ case 'unexpected':
338
+ if (view.nextRetryAt !== undefined) {
339
+ const seconds = Math.max(0, Math.ceil((view.nextRetryAt - now) / 1000))
340
+ return seconds > 0 ? { kind: 'offlineRetry', seconds } : { kind: 'offlineRetrySoon' }
341
+ }
342
+ return view.state === 'unexpected' ? { kind: 'unexpected' } : { kind: 'unreachable' }
343
+ case 'revoked': return { kind: 'revoked' }
344
+ case 'incompatible': return { kind: 'incompatible' }
345
+ case 'invalid-url': return { kind: 'invalidUrl' }
346
+ default: return { kind: 'unpaired' }
347
+ }
348
+ }
349
+
350
+ /** The `POST /_dsh/zen-remote/client/claim` body (T16's pairing round-trip;
351
+ * on success `token` is the gateway-minted device token — it appears exactly
352
+ * once, on its way into the row's secret field). */
353
+ export interface ClaimRouteBody {
354
+ ok?: boolean
355
+ token?: unknown
356
+ deviceId?: unknown
357
+ deviceName?: unknown
358
+ /** The normalized address the backend validated — what the form writes. */
359
+ serverUrl?: unknown
360
+ code?: string
361
+ message?: string
362
+ retryAfterMs?: number
363
+ }
364
+
365
+ /** What the pairing-code box keeps as its draft: uppercase, no spaces or
366
+ * hyphens (the gateway strips every other character at claim time anyway). */
367
+ export function normalizePairingCode(input: string): string {
368
+ return input.toUpperCase().replace(/[\s-]/g, '')
369
+ }
370
+
371
+ /**
372
+ * Whether the settings page may follow the SERVER's record of this device's
373
+ * name into the row (T59): the body carried a name, it differs from the row
374
+ * (nothing to do otherwise — and this equality is the anti-bounce half: the
375
+ * page's own write echoes back through client/status with both sides equal),
376
+ * and the user has no staged draft in the field (their edit wins until they
377
+ * save — a follow mid-typing would clobber it).
378
+ */
379
+ export function shouldFollowServerDeviceName(deviceName: string, rowName: string, hasDraft: boolean): boolean {
380
+ return deviceName !== '' && deviceName !== rowName && !hasDraft
381
+ }
382
+
383
+ /**
384
+ * Latest-wins sequencing for the status loads: every request takes a ticket,
385
+ * and only the newest ticket may still apply its result. An earlier request
386
+ * that answers LATE is dropped, so a stale body can never overwrite a fresh
387
+ * one (a revoked device reappearing, an old "no pairing code" answer wiping
388
+ * a just-minted code, an unpair racing a refresh).
389
+ */
390
+ export interface LatestGate {
391
+ /** Issue the ticket for one new in-flight request. */
392
+ next(): number
393
+ /** Whether that ticket is still the newest issued one. */
394
+ isLatest(ticket: number): boolean
395
+ }
396
+
397
+ export function createLatestGate(): LatestGate {
398
+ let current = 0
399
+ return {
400
+ next: () => { current += 1; return current },
401
+ isLatest: (ticket) => ticket === current,
402
+ }
403
+ }
404
+
405
+ // --- T16-fix 3: the page's role and status-source decisions -------------------
406
+
407
+ /**
408
+ * The SAVED role of the plugin row, read from the configForms snapshot's row
409
+ * document — the settings page's FIRST role signal (T16-fix 3). The resolved
410
+ * role an admin/status body reports is unusable here: a client deployment has
411
+ * no admin route at all, and a stale kept body pinned the page to the old
412
+ * role after a switch. `value` is the schema-resolved section the Host
413
+ * accepted; a row value that only lives in the raw user layer reads from
414
+ * there. Since T17 every row field is volatile, so the form (and this
415
+ * snapshot) carries every stored field — `role` included, with any volatile
416
+ * wrapper already peeled by the host's plainConfig (the dsh-settings
417
+ * describe path calls `value.get()` recursively before the wire).
418
+ *
419
+ * `undefined` when NEITHER layer carries a role: the field's value lives only
420
+ * in a lower layer (`lan-gate.config.json`) or nowhere. That is not "host" —
421
+ * the caller falls back to the effective role the client-config route
422
+ * reports ({@link settingsPollOf}), because a file-layer client row must
423
+ * still render the client page.
424
+ */
425
+ export function savedRowRole(snapshot: { value?: unknown, user?: unknown }): 'host' | 'client' | undefined {
426
+ if (asRecord(snapshot.value).role === 'client') return 'client'
427
+ if (asRecord(snapshot.user).role === 'client') return 'client'
428
+ if (asRecord(snapshot.value).role === 'host') return 'host'
429
+ if (asRecord(snapshot.user).role === 'host') return 'host'
430
+ return undefined
431
+ }
432
+
433
+ /**
434
+ * The EFFECTIVE role for one scope snapshot — the shared two-level decision
435
+ * every role-aware surface uses (the settings page via {@link settingsPollOf},
436
+ * the T33b session-sharing parts through the remote-share registration):
437
+ * while the namespace mirror is still loading the role is not knowable
438
+ * (`'unknown'` — a client deployment must never see a wasted `admin/*` 404);
439
+ * a snapshot whose row carries a role decides from it; a row-silent snapshot
440
+ * falls back to `probed`, the effective role fetched from
441
+ * `/_dsh/mobile-nav/client-config` (T17: the route carries the merged role),
442
+ * and stays `'unknown'` until that probe answers.
443
+ */
444
+ export function settingsRoleOf(
445
+ status: 'loading' | 'ready' | 'unavailable',
446
+ snapshot: { value?: unknown, user?: unknown },
447
+ probed?: 'host' | 'client',
448
+ ): 'unknown' | 'host' | 'client' {
449
+ if (status === 'loading') return 'unknown'
450
+ return savedRowRole(snapshot) ?? probed ?? 'unknown'
451
+ }
452
+
453
+ /**
454
+ * Which status source the page polls for one scope snapshot —
455
+ * {@link settingsRoleOf} mapped onto poll sources (`'unknown'` polls
456
+ * nothing). See there for the decision.
457
+ */
458
+ export function settingsPollOf(
459
+ status: 'loading' | 'ready' | 'unavailable',
460
+ snapshot: { value?: unknown, user?: unknown },
461
+ probed?: 'host' | 'client',
462
+ ): 'none' | 'admin' | 'client' {
463
+ const role = settingsRoleOf(status, snapshot, probed)
464
+ if (role === 'unknown') return 'none'
465
+ return role === 'client' ? 'client' : 'admin'
466
+ }
467
+
468
+ // --- T17: the plugin-reload note ------------------------------------------------
469
+
470
+ /**
471
+ * The row fields whose changed value reloads the plugin row after a save —
472
+ * re-exported from src/restart-fields.ts, the single list the host half's
473
+ * restart watcher fingerprints too (T17b collapsed the two copies; the leaf
474
+ * module is dependency-free so the client bundler can inline it). Only the
475
+ * fields this page edits can ever stage a draft here — `targetPort` /
476
+ * `pushEvents` are not page fields — but the list is the full shared set so
477
+ * a host-side change surfaces in the diff.
478
+ */
479
+ export { RESTART_FIELDS } from '../restart-fields.ts'
480
+
481
+ // --- staged form (row layer) -----------------------------------------------
482
+
483
+ /** One path-addressed edit a save sends (the wire `SettingsPathOpView` shape). */
484
+ export type SettingsFormOp =
485
+ | { op: 'set', path: string[], value: unknown }
486
+ | { op: 'unset', path: string[] }
487
+
488
+ /** Structural subset of the shared `ConfigForm` snapshot this form reads. */
489
+ export interface SettingsFormScopeSnapshot {
490
+ status: 'loading' | 'ready' | 'unavailable'
491
+ value: Record<string, unknown> | undefined
492
+ /** Composition layer; what a field reverts to once cleared. */
493
+ base: unknown
494
+ /** Raw user layer as stored; field PRESENCE here marks an override. */
495
+ user: unknown
496
+ /** Revision fencing the next write; sent back as `expectedRevision`. */
497
+ revision: number | undefined
498
+ writable: boolean
499
+ }
500
+
501
+ /**
502
+ * The form face this controller stages over (structural subset of
503
+ * ui-settings' `ConfigForm` — declared locally because this package does not
504
+ * depend on the ui-settings types).
505
+ */
506
+ export interface SettingsFormScope {
507
+ getSnapshot(): SettingsFormScopeSnapshot
508
+ subscribe(listener: () => void): () => void
509
+ mutate(ops: readonly SettingsFormOp[], expectedRevision?: number): Promise<boolean>
510
+ }
511
+
512
+ /**
513
+ * One secret path in the describe view's sidecar: the path plus whether it
514
+ * is set — the VALUE itself never rides any describe surface.
515
+ */
516
+ export interface DescribeSecret {
517
+ path: string[]
518
+ set: boolean
519
+ }
520
+
521
+ /**
522
+ * The `configForms.describe()` mirror (structural, like {@link ConfigFormsLike}
523
+ * — the dsh-client-ui-settings package is not a dependency). Read only for
524
+ * the secrets sidecar: whether the row's `deviceToken` is set.
525
+ */
526
+ export interface ConfigFormsDescribe {
527
+ getSnapshot(): {
528
+ view?: { namespaces?: ReadonlyArray<{ ns: string, secrets?: ReadonlyArray<DescribeSecret> }> }
529
+ }
530
+ subscribe(listener: () => void): () => void
531
+ }
532
+
533
+ /**
534
+ * The `configForms` service face this plugin uses, mirrored onto the cordis
535
+ * Context. Declared locally (the dsh-client-ui-settings package is not a
536
+ * dependency — same trick as the local `UiWorkspaceLike`): the runtime
537
+ * instance is provided by the host, and the lazy `ctx.inject(['configForms'], …)`
538
+ * in register-settings keeps a composition without the service loadable.
539
+ */
540
+ export interface ConfigFormsLike {
541
+ get(entryId: string): SettingsFormScope
542
+ describe(): ConfigFormsDescribe
543
+ whileServed(namespaces: readonly string[], register: (served: ReadonlySet<string>) => () => void): () => void
544
+ }
545
+
546
+ /** The write one staged draft performs when the form is saved. */
547
+ export type SettingsFieldWrite = { kind: 'set', value: unknown } | { kind: 'clear' }
548
+
549
+ /** How one field converts between its stored value and draft text. */
550
+ export interface SettingsFieldSpec {
551
+ field: string
552
+ format(value: unknown): string
553
+ /** The staged write, or undefined when the draft is not a value this field accepts. */
554
+ parse(text: string): SettingsFieldWrite | undefined
555
+ }
556
+
557
+ /** Free-text field: an empty draft clears, so the field re-inherits its default. */
558
+ export function textField(field: string): SettingsFieldSpec {
559
+ return {
560
+ field,
561
+ format: (value) => (typeof value === 'string' ? value : ''),
562
+ parse: (text) => (text === '' ? { kind: 'clear' } : { kind: 'set', value: text }),
563
+ }
564
+ }
565
+
566
+ /** Whole-number field within inclusive bounds; empty clears, anything else out of range blocks the save. */
567
+ export function intField(field: string, min: number, max: number): SettingsFieldSpec {
568
+ return {
569
+ field,
570
+ format: (value) => (typeof value === 'number' && Number.isFinite(value) ? String(value) : ''),
571
+ parse: (text) => {
572
+ if (text.trim() === '') return { kind: 'clear' }
573
+ const n = Number(text)
574
+ if (!Number.isInteger(n) || n < min || n > max) return undefined
575
+ return { kind: 'set', value: n }
576
+ },
577
+ }
578
+ }
579
+
580
+ /** Finite number in (min, max] — the `idleHours` shape: zero excluded, 8760 allowed. */
581
+ export function hoursField(field: string, max: number): SettingsFieldSpec {
582
+ return {
583
+ field,
584
+ format: (value) => (typeof value === 'number' && Number.isFinite(value) ? String(value) : ''),
585
+ parse: (text) => {
586
+ if (text.trim() === '') return { kind: 'clear' }
587
+ const n = Number(text)
588
+ if (!Number.isFinite(n) || n <= 0 || n > max) return undefined
589
+ return { kind: 'set', value: n }
590
+ },
591
+ }
592
+ }
593
+
594
+ /** `serverName`: an empty draft clears; a blank-but-nonempty draft or one over
595
+ * the 40-character cap is invalid and blocks the save (src/config.ts clips at
596
+ * resolve time, so an over-long write would silently lose its tail). */
597
+ export function nameField(field: string, max: number): SettingsFieldSpec {
598
+ return {
599
+ field,
600
+ format: (value) => (typeof value === 'string' ? value : ''),
601
+ parse: (text) => {
602
+ if (text === '') return { kind: 'clear' }
603
+ if (text.trim() === '' || text.length > max) return undefined
604
+ return { kind: 'set', value: text }
605
+ },
606
+ }
607
+ }
608
+
609
+ /**
610
+ * The name one pairing claim registers under (T57, trimmed since T60): the
611
+ * shared 设备名称 field's CURRENT displayed value — a staged draft when one
612
+ * exists — trimmed FIRST and judged on the trimmed text (a 40-character name
613
+ * with padded spaces is legal; whitespace alone is not a name), returned
614
+ * trimmed so what the claim SENDS is exactly what a later save would store.
615
+ * Falls back to the default device-name copy otherwise. The fallback is
616
+ * load-bearing since T55: a client page reads no admin baseline, so an
617
+ * unstored name displays EMPTY and the claim must still carry a usable name.
618
+ */
619
+ export function claimDeviceNameOf(displayText: string, fallback: string): string {
620
+ const trimmed = displayText.trim()
621
+ return nameField('serverName', SERVER_NAME_MAX).parse(trimmed)?.kind === 'set'
622
+ ? trimmed
623
+ : fallback
624
+ }
625
+
626
+ /** One-of field over a fixed vocabulary (rendered as a select). */
627
+ export function oneOfField(field: string, options: readonly string[]): SettingsFieldSpec {
628
+ return {
629
+ field,
630
+ format: (value) => (typeof value === 'string' && options.includes(value) ? value : options[0] ?? ''),
631
+ parse: (text) => (options.includes(text) ? { kind: 'set', value: text } : undefined),
632
+ }
633
+ }
634
+
635
+ /** Boolean field staged as 'true'/'false' draft text (rendered as a checkbox). */
636
+ export function boolField(field: string): SettingsFieldSpec {
637
+ return {
638
+ field,
639
+ format: (value) => (value === true ? 'true' : 'false'),
640
+ parse: (text) => (text === 'true' || text === 'false' ? { kind: 'set', value: text === 'true' } : undefined),
641
+ }
642
+ }
643
+
644
+ /** Names of the row fields the settings page edits, in group order. */
645
+ export type SettingsFieldName =
646
+ | 'role'
647
+ | 'host'
648
+ | 'port'
649
+ | 'trustedProxies'
650
+ | 'rateLimit'
651
+ | 'vapidSubject'
652
+ | 'pushSummary'
653
+ | 'pushTurnEnd'
654
+ | 'pushTool'
655
+ | 'pushDebounceMs'
656
+ | 'lang'
657
+ | 'serverName'
658
+ | 'idleHours'
659
+ | 'autoShareNewSessions'
660
+
661
+ /** Longest legal `serverName`, mirroring src/config.ts's cap. */
662
+ export const SERVER_NAME_MAX = 40
663
+
664
+ /** Upper bound of `idleHours`, mirroring src/config.ts. */
665
+ export const IDLE_HOURS_MAX = 8760
666
+
667
+ /** The row fields the settings page edits, in render order. */
668
+ export const SETTINGS_FIELDS: readonly SettingsFieldSpec[] = [
669
+ oneOfField('role', ['host', 'client']),
670
+ textField('host'),
671
+ intField('port', 1, 65535),
672
+ textField('trustedProxies'),
673
+ intField('rateLimit', 1, Number.MAX_SAFE_INTEGER),
674
+ textField('vapidSubject'),
675
+ boolField('pushSummary'),
676
+ boolField('pushTurnEnd'),
677
+ boolField('pushTool'),
678
+ intField('pushDebounceMs', 0, Number.MAX_SAFE_INTEGER),
679
+ oneOfField('lang', ['auto', 'zh', 'en']),
680
+ nameField('serverName', SERVER_NAME_MAX),
681
+ hoursField('idleHours', IDLE_HOURS_MAX),
682
+ boolField('autoShareNewSessions'),
683
+ ]
684
+
685
+ // --- the status -> view mapping ---------------------------------------------
686
+
687
+ /**
688
+ * Whether the settings page's server-local operations (pairing, device
689
+ * management, the push probe) are usable: only once a status load answered
690
+ * AND it answered as the local machine (`viaGateway: false`). Everything
691
+ * else — no answer yet, or the page opened through the gateway — keeps them
692
+ * disabled. Pure so the check script pins all three cases.
693
+ */
694
+ export function localOpsAllowed(view: SettingsView | undefined): boolean {
695
+ return view?.viaGateway === false
696
+ }
697
+
698
+ /** Per-field presentation facts the block renders next to each control. */
699
+ export interface SettingsFieldView {
700
+ /** Effective value (the resolved `config.values[field]`). */
701
+ value: unknown
702
+ /** Layer that supplied the effective value. */
703
+ source: ConfigSource
704
+ /** `source === 'env'`: shown locked, edits would be shadowed. */
705
+ locked: boolean
706
+ /** `source === 'file'`: the legacy lan-gate.config.json supplies this. */
707
+ fromFile: boolean
708
+ /** The row layer stores this field but another layer supplied the value —
709
+ * i.e. the saved value was illegal and resolveConfig skipped it. */
710
+ savedRowInvalid: boolean
711
+ }
712
+
713
+ /** One device row the block renders. */
714
+ export interface SettingsDeviceView {
715
+ id: string
716
+ name: string
717
+ role: 'web' | 'desktop-client'
718
+ kind: 'auto' | 'phone' | 'desktop'
719
+ lastSeen: number
720
+ hasPush: boolean
721
+ }
722
+
723
+ /** The live pairing code with its countdown, or null when none is active. */
724
+ export interface SettingsPairingView {
725
+ code: string
726
+ role: 'web' | 'desktop-client'
727
+ remainingSeconds: number
728
+ }
729
+
730
+ /** Everything the settings block renders, derived from one status body. */
731
+ export interface SettingsView {
732
+ /** The status route answered ok. */
733
+ available: boolean
734
+ /** Which half this DSH process runs as (`client` shows only the role group). */
735
+ role: 'host' | 'client'
736
+ /** Opened through the gateway (a remote device): server-local operations disabled. */
737
+ viaGateway: boolean
738
+ /** Whether the gateway child answers. */
739
+ gatewayReachable: boolean
740
+ gatewayPort: number | undefined
741
+ gatewayTarget: string | undefined
742
+ /** The probe status the admin route saw from the gateway; null = no answer. */
743
+ gatewayStatus: number | null
744
+ /** `gatewayStatus` is a non-2xx reply: the status line shows the abnormal message instead. */
745
+ gatewayAbnormal: boolean
746
+ fields: Record<SettingsFieldName, SettingsFieldView>
747
+ devices: SettingsDeviceView[]
748
+ pairing: SettingsPairingView | null
749
+ }
750
+
751
+ export interface DeriveSettingsOptions {
752
+ /** Clock for the pairing countdown; defaults to `Date.now()`. */
753
+ now?: number
754
+ /** The shared form snapshot's raw user layer; field presence marks a row-layer override. */
755
+ rowUser?: unknown
756
+ }
757
+
758
+ const asRecord = (value: unknown): Record<string, unknown> =>
759
+ value !== null && typeof value === 'object' && !Array.isArray(value)
760
+ ? value as Record<string, unknown>
761
+ : {}
762
+
763
+ const asStringSet = (value: unknown): string =>
764
+ typeof value === 'string' ? value : ''
765
+
766
+ /**
767
+ * Map one `admin/status` body into the view the settings block renders.
768
+ * Tolerant by design: every wire field is optional, an unexpected shape
769
+ * degrades to the conservative rendering (status unavailable, gateway down,
770
+ * no devices, no pairing) instead of throwing into the plugin page.
771
+ */
772
+ export function deriveSettingsView(status: AdminStatusBody, options: DeriveSettingsOptions = {}): SettingsView {
773
+ const now = options.now ?? Date.now()
774
+ const values = asRecord(status.config?.values)
775
+ const sources = asRecord(status.config?.sources)
776
+ const rowUser = asRecord(options.rowUser)
777
+ // The gateway payload rides NESTED (`gateway` = /lan-gate/status verbatim);
778
+ // null means the gateway did not answer healthily — devices and the pairing
779
+ // code both render as empty then.
780
+ const gateway = status.gateway === null || typeof status.gateway !== 'object' ? undefined : status.gateway
781
+
782
+ const fields = {} as Record<SettingsFieldName, SettingsFieldView>
783
+ for (const spec of SETTINGS_FIELDS) {
784
+ const rawSource = sources[spec.field]
785
+ const source: ConfigSource =
786
+ rawSource === 'env' || rawSource === 'row' || rawSource === 'file' || rawSource === 'default'
787
+ ? rawSource
788
+ : 'default'
789
+ fields[spec.field as SettingsFieldName] = {
790
+ value: values[spec.field],
791
+ source,
792
+ locked: source === 'env',
793
+ fromFile: source === 'file',
794
+ // Being shadowed by an environment variable is not "invalid" (the lock
795
+ // badge already says so) — only a skipped row value under a non-env
796
+ // winner marks the saved value as rejected by resolveConfig.
797
+ savedRowInvalid: Object.hasOwn(rowUser, spec.field) && source !== 'row' && source !== 'env',
798
+ }
799
+ }
800
+
801
+ const devices: SettingsDeviceView[] = (Array.isArray(gateway?.devices) ? gateway.devices : [])
802
+ .map((device) => {
803
+ const record = asRecord(device)
804
+ const kind = record.kind === 'phone' ? 'phone' as const : record.kind === 'desktop' ? 'desktop' as const : 'auto' as const
805
+ return {
806
+ id: asStringSet(record.id),
807
+ name: asStringSet(record.name),
808
+ role: record.role === 'desktop-client' ? 'desktop-client' as const : 'web' as const,
809
+ kind,
810
+ lastSeen: typeof record.lastSeen === 'number' && Number.isFinite(record.lastSeen) ? record.lastSeen : 0,
811
+ hasPush: record.hasPush === true,
812
+ }
813
+ })
814
+ .filter((device) => device.id !== '')
815
+
816
+ const rawPairing = asRecord(gateway?.pairing)
817
+ const code = asStringSet(rawPairing.code)
818
+ const expiresAt = rawPairing.expiresAt
819
+ const pairing: SettingsPairingView | null =
820
+ code !== '' && typeof expiresAt === 'number' && Number.isFinite(expiresAt) && expiresAt > now
821
+ ? {
822
+ code,
823
+ role: rawPairing.role === 'desktop-client' ? 'desktop-client' : 'web',
824
+ remainingSeconds: Math.max(0, Math.ceil((expiresAt - now) / 1000)),
825
+ }
826
+ : null
827
+
828
+ const gatewayStatus = typeof status.gatewayStatus === 'number' && Number.isFinite(status.gatewayStatus)
829
+ ? status.gatewayStatus
830
+ : null
831
+ return {
832
+ available: status.ok === true,
833
+ role: values.role === 'client' ? 'client' : 'host',
834
+ viaGateway: status.viaGateway === true,
835
+ gatewayReachable: status.gatewayReachable === true,
836
+ gatewayPort: typeof gateway?.port === 'number' && Number.isFinite(gateway.port) ? gateway.port : undefined,
837
+ gatewayTarget: typeof gateway?.target === 'string' ? gateway.target : undefined,
838
+ gatewayStatus,
839
+ // A non-null, non-2xx probe reply: the status line swaps to the abnormal message.
840
+ gatewayAbnormal: gatewayStatus !== null && !(gatewayStatus >= 200 && gatewayStatus < 300),
841
+ fields,
842
+ devices,
843
+ pairing,
844
+ }
845
+ }
846
+
847
+ // --- the controller ----------------------------------------------------------
848
+
849
+ /** Card-level state the shared form frame renders. */
850
+ export interface SettingsFormShellState {
851
+ available: boolean
852
+ writable: boolean
853
+ dirty: boolean
854
+ invalid: boolean
855
+ saving: boolean
856
+ failed: boolean
857
+ }
858
+
859
+ /** One control's state as its field renders it. */
860
+ export interface SettingsFieldState {
861
+ text: string
862
+ overridden: boolean
863
+ invalid: boolean
864
+ /** A clear is staged: the box keeps showing the CURRENT value — the next
865
+ * layer's value only exists after the save — and the field renders the
866
+ * "reverts on save" hint instead of a fake preview. */
867
+ cleared: boolean
868
+ /** `admin/status` reported this field's value locked by an environment variable. */
869
+ locked: boolean
870
+ }
871
+
872
+ /** The row secret's face: presence only, never a value. */
873
+ export interface SettingsSecretFieldState {
874
+ /** The describe view's secrets sidecar reports `deviceToken` set. */
875
+ configured: boolean
876
+ }
877
+
878
+ /** The whole staged-form snapshot the page renders. */
879
+ export interface ZenRemoteFormState extends SettingsFormShellState {
880
+ /** The shared form's scope sync state, reactively exposed so the page's
881
+ * role probe can wait out the mirror's loading phase. */
882
+ scopeStatus: 'loading' | 'ready' | 'unavailable'
883
+ role: SettingsFieldState
884
+ host: SettingsFieldState
885
+ port: SettingsFieldState
886
+ trustedProxies: SettingsFieldState
887
+ rateLimit: SettingsFieldState
888
+ vapidSubject: SettingsFieldState
889
+ pushSummary: SettingsFieldState
890
+ pushTurnEnd: SettingsFieldState
891
+ pushTool: SettingsFieldState
892
+ pushDebounceMs: SettingsFieldState
893
+ lang: SettingsFieldState
894
+ serverName: SettingsFieldState
895
+ idleHours: SettingsFieldState
896
+ autoShareNewSessions: SettingsFieldState
897
+ deviceToken: SettingsSecretFieldState
898
+ /** A restart-required field ({@link RESTART_FIELDS}) has a staged change a
899
+ * save would actually write: the save will move the row and the host
900
+ * reloads it, briefly restarting the gateway — the page shows the reload
901
+ * note while this is true. Since T17b it is not merely "a draft exists":
902
+ * a draft equal to the displayed effective value is no change at all, an
903
+ * invalid draft blocks the save instead of saving anything, and an
904
+ * env-locked field never stages (nor counts if locked after staging). */
905
+ restartPending: boolean
906
+ }
907
+
908
+ /** One staged draft: typed text, or an explicit clear back to the composition layer. */
909
+ type Staged = { text: string } | { clear: true }
910
+
911
+ /**
912
+ * Stages one page's edits over the plugin row's shared form and writes them on
913
+ * save as one atomic, revision-fenced mutation. Fields whose `admin/status`
914
+ * source is `env` are locked via {@link setLockedFields}: they cannot be
915
+ * staged, because a written value would be shadowed by the environment anyway.
916
+ */
917
+ export class ZenRemoteSettingsForm {
918
+ private readonly listeners = new Set<() => void>()
919
+ private readonly staged = new Map<string, Staged>()
920
+ private readonly lockedFields = new Set<string>()
921
+ /** Fields whose saved row value `admin/status` reported invalid
922
+ * (savedRowInvalid): those display the RAW stored value instead of the
923
+ * resolved one, so the user can see (and fix) what they actually wrote. */
924
+ private readonly rowInvalidFields = new Set<string>()
925
+ private readonly scope: SettingsFormScope
926
+ /** Reads the describe view's secrets sidecar for `deviceToken`'s
927
+ * configured flag; injectable so tests run without a describe mirror. */
928
+ private readonly secretConfigured: () => boolean
929
+ /** Effective values from `admin/status`'s `config.values` — what a field
930
+ * displays while the row layer does not carry it. Empty until the page's
931
+ * first status load feeds it via {@link setBaseline}. Consulted only while
932
+ * the page is NOT a client page (T55): see {@link displayValue}. */
933
+ private baseline: Record<string, unknown> = {}
934
+ /** The effective role the page probed from the client-config route (T17):
935
+ * the fallback for {@link savedRoleIsClient} / {@link statusPoll} when the
936
+ * row document cannot answer the saved role (role only in
937
+ * lan-gate.config.json). Undefined until that probe answers. */
938
+ private probedRole: 'host' | 'client' | undefined = undefined
939
+ private saving = false
940
+ private failed = false
941
+ /** Revision the drafts started from; the save's `expectedRevision` fence. */
942
+ private stagedRevision: number | undefined
943
+ private cache: ZenRemoteFormState | undefined
944
+ private readonly unsubscribe: () => void
945
+
946
+ /**
947
+ * @param scope - the shared configuration form for the plugin row entry.
948
+ * (`scope` and `secretConfigured` are assigned in the body rather than as
949
+ * parameter properties: scripts/check-settings-form.mjs imports this module
950
+ * through Node's strip-only type stripping, which rejects that syntax.)
951
+ * @param secretConfigured - whether the row's device token is set, read
952
+ * from the describe view's secrets sidecar; defaults to "not set".
953
+ */
954
+ constructor(scope: SettingsFormScope, secretConfigured: () => boolean = () => false) {
955
+ this.scope = scope
956
+ this.secretConfigured = secretConfigured
957
+ this.unsubscribe = scope.subscribe(() => { this.publish() })
958
+ }
959
+
960
+ /** @returns the current form snapshot (stable reference until the next change). */
961
+ getSnapshot(): ZenRemoteFormState {
962
+ if (this.cache === undefined) this.cache = this.project()
963
+ return this.cache
964
+ }
965
+
966
+ /** Observe snapshot replacements (the renderer binds this as its store). */
967
+ subscribe(listener: () => void): () => void {
968
+ this.listeners.add(listener)
969
+ return () => { this.listeners.delete(listener) }
970
+ }
971
+
972
+ /** Republish from the current reads (admin/status moved underneath). */
973
+ refresh(): void {
974
+ this.publish()
975
+ }
976
+
977
+ /** Replace the set of environment-locked fields reported by admin/status. */
978
+ setLockedFields(fields: Iterable<string>): void {
979
+ this.lockedFields.clear()
980
+ for (const field of fields) this.lockedFields.add(field)
981
+ this.publish()
982
+ }
983
+
984
+ /**
985
+ * Replace the set of fields whose saved row value resolveConfig rejected
986
+ * (admin/status's savedRowInvalid). Those keep showing the RAW stored
987
+ * value — the resolved value belongs to another layer and would hide the
988
+ * mistake the user needs to fix.
989
+ */
990
+ setRowInvalidFields(fields: Iterable<string>): void {
991
+ this.rowInvalidFields.clear()
992
+ for (const field of fields) this.rowInvalidFields.add(field)
993
+ this.publish()
994
+ }
995
+
996
+ /**
997
+ * The entry document's stored value for one field (the shared form
998
+ * snapshot's `value` — the redacted section: secrets never ride it). What
999
+ * the client group prefills the server address from, and what the page
1000
+ * reads the SAVED role from (the role field's display text also carries
1001
+ * staged drafts, which must not flip the page's mode).
1002
+ */
1003
+ rowValue(field: string): unknown {
1004
+ return this.scope.getSnapshot().value?.[field]
1005
+ }
1006
+
1007
+ /**
1008
+ * The scope sync state as the shared form sees it. The page waits it out
1009
+ * before choosing a status source: a 'loading' snapshot cannot answer the
1010
+ * role yet (T16-fix 3).
1011
+ */
1012
+ scopeStatus(): 'loading' | 'ready' | 'unavailable' {
1013
+ return this.scope.getSnapshot().status
1014
+ }
1015
+
1016
+ /**
1017
+ * Which status source the page should poll right now ({@link settingsPollOf}
1018
+ * over the live snapshot): `none` while the mirror loads — and while a row
1019
+ * without a stored role waits for the client-config probe — `client` for a
1020
+ * client row — never admin there — and `admin` otherwise.
1021
+ */
1022
+ statusPoll(): 'none' | 'admin' | 'client' {
1023
+ const snap = this.scope.getSnapshot()
1024
+ return settingsPollOf(snap.status, snap, this.probedRole)
1025
+ }
1026
+
1027
+ /**
1028
+ * Feed the fallback role the page probed from the client-config route
1029
+ * (T17). Only consulted when the row document carries no `role` — a stored
1030
+ * row role always wins, so a staged-then-saved switch is never masked by a
1031
+ * stale probe.
1032
+ */
1033
+ setProbedRole(role: 'host' | 'client' | undefined): void {
1034
+ this.probedRole = role
1035
+ this.publish()
1036
+ }
1037
+
1038
+ /**
1039
+ * Whether the SAVED row role is client ({@link savedRowRole} over the live
1040
+ * snapshot, falling back to the probed role) — the page mode's single
1041
+ * source of truth.
1042
+ */
1043
+ savedRoleIsClient(): boolean {
1044
+ return (savedRowRole(this.scope.getSnapshot()) ?? this.probedRole ?? 'host') === 'client'
1045
+ }
1046
+
1047
+ /**
1048
+ * Whether the row document answers the saved role at all (T17): false when
1049
+ * neither snapshot layer carries one — the page then probes the
1050
+ * client-config route for the effective role instead of assuming host.
1051
+ */
1052
+ rowRoleKnown(): boolean {
1053
+ return savedRowRole(this.scope.getSnapshot()) !== undefined
1054
+ }
1055
+
1056
+ /**
1057
+ * One direct write for the pairing flow (T16): the normalized server
1058
+ * address, the token just redeemed from the server, and (T60) the name
1059
+ * actually registered with — the SERVER's confirmed echo when the claim
1060
+ * carried one, else the name the claim sent — ride ONE revision-fenced
1061
+ * mutate. The name lands in the row's `serverName` (the role card's 设备
1062
+ * 名称 field), so a pairing that used an UNSAVED draft can never leave the
1063
+ * local row empty while the server records the name. Not a staged edit —
1064
+ * all three fields are volatile row settings and apply immediately. The
1065
+ * caller drops the field's stale draft afterwards (see
1066
+ * {@link discardField}). @returns whether the write landed.
1067
+ */
1068
+ async writeClientPairing(serverUrl: string, token: string, deviceName: string): Promise<boolean> {
1069
+ return this.directWrite([
1070
+ { op: 'set', path: ['serverUrl'], value: serverUrl },
1071
+ { op: 'set', path: [DEVICE_TOKEN_FIELD], value: token },
1072
+ { op: 'set', path: ['serverName'], value: deviceName },
1073
+ ])
1074
+ }
1075
+
1076
+ /**
1077
+ * One direct write for unpairing: forget the token, keep the address so
1078
+ * the next pairing only needs a fresh code.
1079
+ */
1080
+ async clearDeviceToken(): Promise<boolean> {
1081
+ return this.directWrite([{ op: 'unset', path: [DEVICE_TOKEN_FIELD] }])
1082
+ }
1083
+
1084
+ /**
1085
+ * Drop ONE field's staged draft (T60): after the pairing write landed the
1086
+ * name in the row, the draft that produced it is stale — it now equals the
1087
+ * displayed value, and keeping it would show a phantom "overridden" badge.
1088
+ * The revision fence goes with the last draft (there is nothing staged
1089
+ * left to fence); an in-flight save-failure flag is not touched — that is
1090
+ * the staged-save surface's own story.
1091
+ */
1092
+ discardField(field: string): void {
1093
+ if (!this.staged.delete(field)) return
1094
+ if (this.staged.size === 0) this.stagedRevision = undefined
1095
+ this.publish()
1096
+ }
1097
+
1098
+ /**
1099
+ * Whether one field currently has a STAGED edit (a typed draft or a staged
1100
+ * clear) — the "the user is mid-edit" fact the T59 server-name follow
1101
+ * reads before it may write. Reads the live draft map, so it answers per
1102
+ * call; the page re-runs its effect on every republished snapshot.
1103
+ */
1104
+ hasDraft(field: string): boolean {
1105
+ return this.staged.has(field)
1106
+ }
1107
+
1108
+ /**
1109
+ * One direct write for the T59 name follow: the SERVER's record of this
1110
+ * device's name landing in the row's `serverName` (the role card's 设备
1111
+ * 名称 field). The page only calls it when the user is not mid-edit —
1112
+ * see {@link shouldFollowServerDeviceName} — and the write's own volatile
1113
+ * update echoes back to the backend with both sides now equal, so the
1114
+ * push path stays silent (no overwrite loop).
1115
+ */
1116
+ async writeDeviceName(name: string): Promise<boolean> {
1117
+ return this.directWrite([{ op: 'set', path: ['serverName'], value: name }])
1118
+ }
1119
+
1120
+ /**
1121
+ * The shared write path of the two pairing flows. Deliberately does NOT
1122
+ * touch the frame's `failed` flag — a refused pairing write surfaces in
1123
+ * the client group's own copy, not as a staged-save failure.
1124
+ */
1125
+ private async directWrite(ops: SettingsFormOp[]): Promise<boolean> {
1126
+ const snap = this.scope.getSnapshot()
1127
+ if (snap.status !== 'ready' || !snap.writable || this.saving) return false
1128
+ this.saving = true
1129
+ this.publish()
1130
+ try {
1131
+ const landed = await this.scope.mutate(ops, snap.revision)
1132
+ if (!landed) return false
1133
+ // The row moved underneath any staged drafts: their revision fence is
1134
+ // stale, so the next save fences from the fresh revision instead of
1135
+ // being refused by a conflict it did not cause.
1136
+ this.stagedRevision = undefined
1137
+ return true
1138
+ } catch {
1139
+ return false
1140
+ } finally {
1141
+ this.saving = false
1142
+ this.publish()
1143
+ }
1144
+ }
1145
+
1146
+ /**
1147
+ * Feed the effective values (`admin/status`'s `config.values`) the fields
1148
+ * display while the row layer does not carry them; also the baseline the
1149
+ * "did the user change anything" comparison reads. The page clears it
1150
+ * (`undefined`) whenever the poll source is not the admin one (T55): a
1151
+ * stale host baseline must not survive a role switch on a client page.
1152
+ */
1153
+ setBaseline(values: unknown): void {
1154
+ this.baseline = asRecord(values)
1155
+ this.publish()
1156
+ }
1157
+
1158
+ /** Stage draft text for one row field; ignored for env-locked fields. */
1159
+ stage(field: string, text: string): void {
1160
+ if (this.lockedFields.has(field)) return
1161
+ this.staged.set(field, { text })
1162
+ this.noteStage()
1163
+ }
1164
+
1165
+ /** Stage a clear so the field re-inherits the composition layer; ignored for env-locked fields. */
1166
+ resetField(field: string): void {
1167
+ if (this.lockedFields.has(field)) return
1168
+ this.staged.set(field, { clear: true })
1169
+ this.noteStage()
1170
+ }
1171
+
1172
+ /** Drop every staged edit. */
1173
+ discard(): void {
1174
+ if (this.staged.size === 0 && !this.failed) return
1175
+ this.staged.clear()
1176
+ this.stagedRevision = undefined
1177
+ this.failed = false
1178
+ this.publish()
1179
+ }
1180
+
1181
+ /**
1182
+ * The raw user layer as the shared form stores it; a field's presence here
1183
+ * marks a row-layer override, which is what `savedRowInvalid` compares
1184
+ * against.
1185
+ */
1186
+ rowUser(): Record<string, unknown> {
1187
+ return asRecord(this.scope.getSnapshot().user)
1188
+ }
1189
+
1190
+ /** Whether a save would do anything and is allowed to run right now. */
1191
+ canSave(): boolean {
1192
+ const snap = this.scope.getSnapshot()
1193
+ return snap.status === 'ready'
1194
+ && snap.writable
1195
+ && !this.saving
1196
+ && this.plan().length > 0
1197
+ && !this.anyInvalid()
1198
+ }
1199
+
1200
+ /**
1201
+ * Write every staged edit as one mutation fenced by the revision the drafts
1202
+ * started from. The Host is the only authority on acceptance: a refused
1203
+ * save keeps its drafts for correction.
1204
+ * @returns whether the save landed.
1205
+ */
1206
+ async save(): Promise<boolean> {
1207
+ const ops = this.plan()
1208
+ const snap = this.scope.getSnapshot()
1209
+ if (ops.length === 0 || this.saving || snap.status !== 'ready' || !snap.writable || this.anyInvalid()) return false
1210
+ this.saving = true
1211
+ this.failed = false
1212
+ this.publish()
1213
+ try {
1214
+ const landed = await this.scope.mutate(ops, this.stagedRevision ?? snap.revision)
1215
+ if (!landed) {
1216
+ this.failed = true
1217
+ return false
1218
+ }
1219
+ this.staged.clear()
1220
+ this.stagedRevision = undefined
1221
+ return true
1222
+ } catch {
1223
+ this.failed = true
1224
+ return false
1225
+ } finally {
1226
+ this.saving = false
1227
+ this.publish()
1228
+ }
1229
+ }
1230
+
1231
+ /** Release the scope subscription. */
1232
+ dispose(): void {
1233
+ this.unsubscribe()
1234
+ this.listeners.clear()
1235
+ }
1236
+
1237
+ private noteStage(): void {
1238
+ if (this.stagedRevision === undefined) this.stagedRevision = this.scope.getSnapshot().revision
1239
+ this.failed = false
1240
+ this.publish()
1241
+ }
1242
+
1243
+ /** Whether the user layer currently carries an entry for one field. */
1244
+ private stored(field: string): boolean {
1245
+ return Object.hasOwn(asRecord(this.scope.getSnapshot().user), field)
1246
+ }
1247
+
1248
+ /**
1249
+ * The value one field DISPLAYS, which is also the baseline a draft must
1250
+ * differ from to count as a change: an env-locked field shows the effective
1251
+ * value (a written one would be shadowed anyway); a field the row layer
1252
+ * stores shows the RESOLVED value from admin/status — resolveConfig
1253
+ * normalizes stored shapes (a trustedProxies array becomes the comma
1254
+ * string, pushTool 1 becomes true) and the box must match what a save
1255
+ * writes — EXCEPT when the saved row value was rejected (savedRowInvalid):
1256
+ * then the raw stored value shows, so the user sees the mistake; before
1257
+ * the first status load the stored raw value stands in, and otherwise the
1258
+ * shared form's own effective layer does, so drafts behave sensibly even
1259
+ * with no admin/status yet.
1260
+ *
1261
+ * T55: the admin/status baseline counts only while the page is not a CLIENT
1262
+ * page. A role switch leaves the host page's kept status load (and with it
1263
+ * the last-fed baseline) in place forever — a client page never refreshes
1264
+ * admin/status again — so reading the baseline there would pin every field,
1265
+ * the role dropdown included, to the old role. Under a client poll the
1266
+ * fields fall back to the row's stored values (the page stops feeding the
1267
+ * baseline too, {@link setBaseline}).
1268
+ */
1269
+ private displayValue(field: string): unknown {
1270
+ const staleAdmin = this.statusPoll() === 'client'
1271
+ if (this.lockedFields.has(field)) {
1272
+ // Lock sets only ever arrive from an admin/status load; while a stale
1273
+ // set lingers after a switch, the row document stands in.
1274
+ return staleAdmin ? this.scope.getSnapshot().value?.[field] : this.baseline[field]
1275
+ }
1276
+ const user = asRecord(this.scope.getSnapshot().user)
1277
+ const rowStored = Object.hasOwn(user, field)
1278
+ if (rowStored && this.rowInvalidFields.has(field)) return user[field]
1279
+ if (!staleAdmin && Object.hasOwn(this.baseline, field)) return this.baseline[field]
1280
+ if (rowStored) return user[field]
1281
+ return this.scope.getSnapshot().value?.[field]
1282
+ }
1283
+
1284
+ private anyInvalid(): boolean {
1285
+ return SETTINGS_FIELDS.some((spec) => {
1286
+ const draft = this.staged.get(spec.field)
1287
+ return draft !== undefined && 'text' in draft && spec.parse(draft.text) === undefined
1288
+ })
1289
+ }
1290
+
1291
+ /**
1292
+ * Every staged edit a save would send, in field order, mirroring the
1293
+ * upstream form model's plan: a draft equal to the field's current value is
1294
+ * no change at all; a reset only writes when the user layer actually
1295
+ * carries the field; an invalid draft produces none — the save refuses
1296
+ * rather than dropping the edit; a locked field never produces one.
1297
+ */
1298
+ private plan(): SettingsFormOp[] {
1299
+ const ops: SettingsFormOp[] = []
1300
+ for (const spec of SETTINGS_FIELDS) {
1301
+ if (this.lockedFields.has(spec.field)) continue
1302
+ const draft = this.staged.get(spec.field)
1303
+ if (draft === undefined) continue
1304
+ if ('clear' in draft) {
1305
+ if (this.stored(spec.field)) ops.push({ op: 'unset', path: [spec.field] })
1306
+ continue
1307
+ }
1308
+ if (draft.text === spec.format(this.displayValue(spec.field))) continue
1309
+ const write = spec.parse(draft.text)
1310
+ if (write === undefined) continue
1311
+ ops.push(write.kind === 'clear'
1312
+ ? { op: 'unset', path: [spec.field] }
1313
+ : { op: 'set', path: [spec.field], value: write.value })
1314
+ }
1315
+ return ops
1316
+ }
1317
+
1318
+ private project(): ZenRemoteFormState {
1319
+ const snap = this.scope.getSnapshot()
1320
+ const user = asRecord(snap.user)
1321
+ const fields = {} as Record<SettingsFieldName, SettingsFieldState>
1322
+ for (const spec of SETTINGS_FIELDS) {
1323
+ const draft = this.staged.get(spec.field)
1324
+ const stagedClear = draft !== undefined && 'clear' in draft
1325
+ const stagedText = draft !== undefined && 'text' in draft ? draft.text : undefined
1326
+ // A staged clear keeps showing the CURRENT value: the next layer's
1327
+ // value does not exist yet (and base is empty for a plugin row), so
1328
+ // there is nothing honest to preview — the field instead renders the
1329
+ // "reverts on save" hint off `cleared`.
1330
+ const text = stagedText ?? spec.format(this.displayValue(spec.field))
1331
+ const overridden = draft === undefined
1332
+ ? Object.hasOwn(user, spec.field)
1333
+ : 'clear' in draft
1334
+ ? false
1335
+ : spec.parse(draft.text)?.kind === 'set'
1336
+ const invalid = stagedText !== undefined && spec.parse(stagedText) === undefined
1337
+ fields[spec.field as SettingsFieldName] = {
1338
+ text,
1339
+ overridden,
1340
+ invalid,
1341
+ cleared: stagedClear,
1342
+ locked: this.lockedFields.has(spec.field),
1343
+ }
1344
+ }
1345
+ // One plan drives both flags: dirty is "any write at all", and
1346
+ // restartPending (T17b) is "a write touching a restart-required field" —
1347
+ // derived from plan()'s own op decisions (T17b-fix), never restated, so
1348
+ // the reload note can never disagree with what a save would actually
1349
+ // send: an identical draft, an invalid draft, a locked field and a clear
1350
+ // of a field the row layer does not carry all plan no op here either.
1351
+ const ops = this.plan()
1352
+ const restartFields = RESTART_FIELDS as readonly string[]
1353
+ return {
1354
+ available: snap.status === 'ready',
1355
+ writable: snap.writable,
1356
+ scopeStatus: snap.status,
1357
+ dirty: ops.length > 0,
1358
+ invalid: this.anyInvalid(),
1359
+ saving: this.saving,
1360
+ failed: this.failed,
1361
+ restartPending: ops.some((op) => op.path[0] !== undefined && restartFields.includes(op.path[0])),
1362
+ ...fields,
1363
+ deviceToken: { configured: this.secretConfigured() },
1364
+ }
1365
+ }
1366
+
1367
+ private publish(): void {
1368
+ this.cache = this.project()
1369
+ for (const listener of this.listeners) listener()
1370
+ }
1371
+ }