dsh-zen-remote 2.3.11 → 2.4.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 (244) hide show
  1. package/CHANGELOG.md +39 -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 +564 -363
  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-store.js +65 -27
  70. package/lib/share-store.js.map +1 -1
  71. package/lib/state-store.js +400 -0
  72. package/lib/state-store.js.map +1 -0
  73. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
  74. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  75. package/lib/types/admin-routes.d.ts.map +1 -1
  76. package/lib/types/cli/args.d.ts +78 -0
  77. package/lib/types/cli/args.d.ts.map +1 -0
  78. package/lib/types/cli/bootstrap.d.ts +170 -0
  79. package/lib/types/cli/bootstrap.d.ts.map +1 -0
  80. package/lib/types/cli/command-setup.d.ts +72 -0
  81. package/lib/types/cli/command-setup.d.ts.map +1 -0
  82. package/lib/types/cli/commands-client.d.ts +36 -0
  83. package/lib/types/cli/commands-client.d.ts.map +1 -0
  84. package/lib/types/cli/commands-host.d.ts +82 -0
  85. package/lib/types/cli/commands-host.d.ts.map +1 -0
  86. package/lib/types/cli/commands-status.d.ts +46 -0
  87. package/lib/types/cli/commands-status.d.ts.map +1 -0
  88. package/lib/types/cli/config-writer.d.ts +92 -0
  89. package/lib/types/cli/config-writer.d.ts.map +1 -0
  90. package/lib/types/cli/context.d.ts +39 -0
  91. package/lib/types/cli/context.d.ts.map +1 -0
  92. package/lib/types/cli/i18n.d.ts +32 -0
  93. package/lib/types/cli/i18n.d.ts.map +1 -0
  94. package/lib/types/cli/ipc-client.d.ts +83 -0
  95. package/lib/types/cli/ipc-client.d.ts.map +1 -0
  96. package/lib/types/cli/ipc-server.d.ts +113 -0
  97. package/lib/types/cli/ipc-server.d.ts.map +1 -0
  98. package/lib/types/cli/main.d.ts +57 -0
  99. package/lib/types/cli/main.d.ts.map +1 -0
  100. package/lib/types/cli/qr-terminal.d.ts +17 -0
  101. package/lib/types/cli/qr-terminal.d.ts.map +1 -0
  102. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
  103. package/lib/types/client/current-session-report.d.ts +4 -0
  104. package/lib/types/client/current-session-report.d.ts.map +1 -1
  105. package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
  106. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  107. package/lib/types/client/locales.d.ts +47 -3
  108. package/lib/types/client/locales.d.ts.map +1 -1
  109. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  110. package/lib/types/client/settings/settings-css.d.ts +1 -1
  111. package/lib/types/client/settings/settings-css.d.ts.map +1 -1
  112. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  113. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  114. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  115. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
  116. package/lib/types/client/wizard/wizard-css.d.ts +1 -1
  117. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
  118. package/lib/types/client-data/model-group-side.d.ts +23 -2
  119. package/lib/types/client-data/model-group-side.d.ts.map +1 -1
  120. package/lib/types/client-data/remote-status.d.ts +47 -1
  121. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  122. package/lib/types/client-data/settings-form.d.ts +85 -1
  123. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  124. package/lib/types/client-data/wizard.d.ts +56 -1
  125. package/lib/types/client-data/wizard.d.ts.map +1 -1
  126. package/lib/types/client-routes.d.ts +25 -1
  127. package/lib/types/client-routes.d.ts.map +1 -1
  128. package/lib/types/config.d.ts +40 -14
  129. package/lib/types/config.d.ts.map +1 -1
  130. package/lib/types/debug-report.d.ts.map +1 -1
  131. package/lib/types/fetch-route-intercept.d.ts +66 -8
  132. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  133. package/lib/types/index.d.ts +9 -2
  134. package/lib/types/index.d.ts.map +1 -1
  135. package/lib/types/intercept.d.ts +72 -10
  136. package/lib/types/intercept.d.ts.map +1 -1
  137. package/lib/types/merge-streams.d.ts +79 -1
  138. package/lib/types/merge-streams.d.ts.map +1 -1
  139. package/lib/types/plugin-route-intercept.d.ts +25 -9
  140. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  141. package/lib/types/relay-access.d.ts +3 -1
  142. package/lib/types/relay-access.d.ts.map +1 -1
  143. package/lib/types/relay-client.d.ts +23 -2
  144. package/lib/types/relay-client.d.ts.map +1 -1
  145. package/lib/types/relay-pool.d.ts +148 -0
  146. package/lib/types/relay-pool.d.ts.map +1 -0
  147. package/lib/types/relay-server.d.ts +30 -3
  148. package/lib/types/relay-server.d.ts.map +1 -1
  149. package/lib/types/restart-fields.d.ts +5 -5
  150. package/lib/types/restart-fields.d.ts.map +1 -1
  151. package/lib/types/share-store.d.ts.map +1 -1
  152. package/lib/types/state-store.d.ts +179 -0
  153. package/lib/types/state-store.d.ts.map +1 -0
  154. package/lib/types/upload-limits.d.ts +15 -0
  155. package/lib/types/upload-limits.d.ts.map +1 -1
  156. package/lib/types/virtual-id.d.ts +21 -0
  157. package/lib/types/virtual-id.d.ts.map +1 -1
  158. package/lib/types/wizard/probe.d.ts.map +1 -1
  159. package/lib/types/wizard/proxy-snippets.d.ts +3 -0
  160. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  161. package/lib/types/wizard/routes.d.ts +1 -0
  162. package/lib/types/wizard/routes.d.ts.map +1 -1
  163. package/lib/types/wizard/status.d.ts +9 -0
  164. package/lib/types/wizard/status.d.ts.map +1 -1
  165. package/lib/types/wizard/verify.d.ts +3 -0
  166. package/lib/types/wizard/verify.d.ts.map +1 -1
  167. package/lib/upload-limits.js +15 -0
  168. package/lib/upload-limits.js.map +1 -1
  169. package/lib/virtual-id.js +60 -0
  170. package/lib/virtual-id.js.map +1 -1
  171. package/lib/wizard/probe.js +46 -1
  172. package/lib/wizard/probe.js.map +1 -1
  173. package/lib/wizard/proxy-snippets.js +14 -1
  174. package/lib/wizard/proxy-snippets.js.map +1 -1
  175. package/lib/wizard/routes.js +23 -4
  176. package/lib/wizard/routes.js.map +1 -1
  177. package/lib/wizard/status.js +21 -0
  178. package/lib/wizard/status.js.map +1 -1
  179. package/lib/wizard/verify.js +31 -8
  180. package/lib/wizard/verify.js.map +1 -1
  181. package/package.json +8 -4
  182. package/pwa/icons/badge-96.png +0 -0
  183. package/pwa/icons/badge.svg +1 -0
  184. package/pwa/manifest.json +2 -1
  185. package/pwa/sw.js +3 -1
  186. package/scripts/check-debug-report.mjs +41 -1
  187. package/scripts/check-settings-form.mjs +14 -3
  188. package/scripts/check-settings-view.mjs +247 -0
  189. package/scripts/check-wizard-ui.mjs +37 -0
  190. package/src/adapters/dsh-better-sidebar/index.ts +7 -3
  191. package/src/admin-routes.ts +10 -2
  192. package/src/cli/args.ts +289 -0
  193. package/src/cli/bootstrap.ts +569 -0
  194. package/src/cli/command-setup.ts +540 -0
  195. package/src/cli/commands-client.ts +369 -0
  196. package/src/cli/commands-host.ts +1127 -0
  197. package/src/cli/commands-status.ts +296 -0
  198. package/src/cli/config-writer.ts +354 -0
  199. package/src/cli/context.ts +38 -0
  200. package/src/cli/i18n.ts +645 -0
  201. package/src/cli/ipc-client.ts +216 -0
  202. package/src/cli/ipc-server.ts +531 -0
  203. package/src/cli/main.ts +285 -0
  204. package/src/cli/qr-terminal.ts +49 -0
  205. package/src/client/RemoteStatusIcon.tsx +4 -1
  206. package/src/client/current-session-report.ts +28 -4
  207. package/src/client/effects/model-group-side.ts +19 -5
  208. package/src/client/effects/remote-session.ts +26 -6
  209. package/src/client/locales.ts +96 -6
  210. package/src/client/settings/SettingsSection.tsx +287 -92
  211. package/src/client/settings/settings-css.ts +18 -0
  212. package/src/client/styles/remote-session.css.ts +15 -1
  213. package/src/client/wizard/GatewayWizard.tsx +470 -109
  214. package/src/client/wizard/cards/ProxyCard.tsx +2 -1
  215. package/src/client/wizard/wizard-css.ts +23 -0
  216. package/src/client-data/model-group-side.ts +37 -10
  217. package/src/client-data/remote-status.ts +112 -17
  218. package/src/client-data/settings-form.ts +164 -8
  219. package/src/client-data/wizard.ts +112 -2
  220. package/src/client-routes.ts +334 -26
  221. package/src/config.ts +62 -16
  222. package/src/debug-report.ts +27 -1
  223. package/src/fetch-route-intercept.ts +381 -32
  224. package/src/index.ts +566 -367
  225. package/src/intercept.ts +1866 -286
  226. package/src/merge-streams.ts +155 -10
  227. package/src/plugin-route-intercept.ts +120 -71
  228. package/src/relay-access.ts +14 -3
  229. package/src/relay-client.ts +163 -5
  230. package/src/relay-pool.ts +387 -0
  231. package/src/relay-server.ts +312 -16
  232. package/src/restart-fields.ts +6 -4
  233. package/src/share-store.ts +59 -26
  234. package/src/state-store.ts +428 -0
  235. package/src/upload-limits.ts +16 -0
  236. package/src/virtual-id.ts +57 -0
  237. package/src/wizard/probe.ts +54 -2
  238. package/src/wizard/proxy-snippets.ts +17 -2
  239. package/src/wizard/routes.ts +24 -5
  240. package/src/wizard/status.ts +28 -0
  241. package/src/wizard/verify.ts +33 -11
  242. package/AGENTS.md +0 -185
  243. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
  244. package/docs/adr/0002-relay-emulates-websocket.md +0 -8
@@ -0,0 +1,387 @@
1
+ /**
2
+ * The multi-server relay connection pool (MS-2): up to {@link MAX_PAIRED_SERVERS}
3
+ * `RelayClient` instances, one per paired server in the state file, replacing
4
+ * the single row-driven client the client role used to keep. The paired
5
+ * list itself lives in `zen-remote-state.json` (src/state-store.ts — MS-1's
6
+ * three-in-one file); this module owns the LIVE half: start, handshake
7
+ * bookkeeping, re-pair, unpair, and the second dedupe layer's teardown.
8
+ *
9
+ * Two bookkeeping paths meet here. When one client's handshake lands, the
10
+ * handshake facts (serverId/serverName/deviceName) are persisted onto its
11
+ * state-file record — `recordServerHandshake` also REMOVES any other record
12
+ * that carries the same serverId (the same server re-paired under a new
13
+ * address), and this module stops that stale entry's client and announces it
14
+ * with `reason: 'dedupe'`. Removals the USER makes go out with
15
+ * `reason: 'unpair'`.
16
+ *
17
+ * Display names: several servers report the same `serverName` (the default is
18
+ * just the hostname), so the merged sidebar would show N identical rows.
19
+ * {@link deriveDisplayServerNames} numbers duplicates in pairing order
20
+ * (`home-1`, `home-2`, …) and leaves unique names bare; a rename that removes
21
+ * the collision removes the suffixes with it, because the derivation is
22
+ * recomputed on every `entries()` read.
23
+ *
24
+ * The clients themselves are the plain T23a relay clients — their getters
25
+ * read the pool's in-memory record per request, so a re-pair only updates
26
+ * that record and calls `credentialsChanged()`. `createClient` is injectable:
27
+ * the tests drive the whole lifecycle without a socket.
28
+ */
29
+
30
+ import { createRelayClient } from './relay-client.js'
31
+ import type { RelayClient, RelayClock, RelayState, CreateRelayClientOptions } from './relay-client.js'
32
+ import type { PairedServerRecord } from './state-store.js'
33
+ import {
34
+ clearPairedServers,
35
+ listPairedServers,
36
+ MAX_PAIRED_SERVERS,
37
+ migrateLegacyClientIntoServers,
38
+ recordServerHandshake,
39
+ removePairedServer,
40
+ upsertPairedServer,
41
+ } from './state-store.js'
42
+
43
+ /** One entry's name inputs for {@link deriveDisplayServerNames}. */
44
+ export interface ServerNameEntry {
45
+ entryId: string
46
+ serverName: string
47
+ serverUrl: string
48
+ }
49
+
50
+ /**
51
+ * The display name of every entry: a trimmed non-empty `serverName` is the
52
+ * base, otherwise the server URL. Bases appearing once keep their name; bases
53
+ * appearing twice or more are numbered `-1`, `-2`, `-3`… in `entries` order
54
+ * (pairing order). Pure — recomputed whenever the pool is asked for entries,
55
+ * so a rename that removes a collision removes the suffixes too.
56
+ */
57
+ export function deriveDisplayServerNames(entries: readonly ServerNameEntry[]): Map<string, string> {
58
+ const baseOf = (entry: ServerNameEntry): string =>
59
+ typeof entry.serverName === 'string' && entry.serverName.trim() !== '' ? entry.serverName.trim() : entry.serverUrl
60
+ const counts = new Map<string, number>()
61
+ for (const entry of entries) {
62
+ const base = baseOf(entry)
63
+ counts.set(base, (counts.get(base) ?? 0) + 1)
64
+ }
65
+ const seen = new Map<string, number>()
66
+ const out = new Map<string, string>()
67
+ for (const entry of entries) {
68
+ const base = baseOf(entry)
69
+ if ((counts.get(base) ?? 0) >= 2) {
70
+ const n = (seen.get(base) ?? 0) + 1
71
+ seen.set(base, n)
72
+ out.set(entry.entryId, `${base}-${n}`)
73
+ } else {
74
+ out.set(entry.entryId, base)
75
+ }
76
+ }
77
+ return out
78
+ }
79
+
80
+ /** One live pool entry as the callers see it. */
81
+ export interface RelayPoolEntryView {
82
+ /** The stable state-file record id. */
83
+ entryId: string
84
+ /** The normalized server address. */
85
+ serverUrl: string
86
+ /** The handshake's server id; the persisted record id until one lands. */
87
+ serverId: string | undefined
88
+ /** The handshake's server name; the persisted name until one lands. */
89
+ serverName: string | undefined
90
+ /** {@link deriveDisplayServerNames} over the current entries. */
91
+ displayName: string
92
+ /** The live client. */
93
+ client: RelayClient
94
+ /** When the pairing was first added (epoch ms). */
95
+ createdAt: number
96
+ }
97
+
98
+ /** Why an entry left the pool: the user removed it, or a handshake proved it
99
+ * a stale duplicate of another entry pointing at the same server. */
100
+ export type RelayPoolRemovalReason = 'unpair' | 'dedupe'
101
+
102
+ export interface RelayPoolOptions {
103
+ /** The state file (`<DSH_HOME>/zen-remote-state.json`). */
104
+ stateFile: string
105
+ /** The row's legacy single-server credentials, read once at startup: when
106
+ * the paired list is still empty they become `servers[0]`. */
107
+ legacySource?: () => { serverUrl?: string; deviceToken?: string } | undefined
108
+ /** This side's own interface fingerprints (T42), handed to every client.
109
+ * May be async (the real wiring computes against the typert registry,
110
+ * which loads package by package); the clients await it per handshake. */
111
+ computeOwnFingerprints: () => Record<string, string> | Promise<Record<string, string>>
112
+ /** Outbound fetch for the clients; defaults to the global one. */
113
+ fetchImpl?: typeof fetch
114
+ /** Clock for the clients' reconnect machinery; defaults to the real one. */
115
+ clock?: RelayClock
116
+ /** The client factory; injectable so tests run without sockets. */
117
+ createClient?: typeof createRelayClient
118
+ /** Every client state change, with the entry it belongs to. */
119
+ onStateChange?: (event: { entryId: string; serverId?: string; state: RelayState; lastError?: string }) => void
120
+ /** Every entry removal, with why it left. */
121
+ onServerRemoved?: (removed: { entryId: string; serverId?: string; reason: RelayPoolRemovalReason }) => void
122
+ }
123
+
124
+ /** One in-memory entry: the record (mutated in place on re-pair/handshake),
125
+ * its client and the state-listener unsubscribe handle. */
126
+ interface PoolEntry {
127
+ record: PairedServerRecord
128
+ client: RelayClient
129
+ unsubscribe: () => void
130
+ }
131
+
132
+ export interface RelayPool {
133
+ /** Every entry in pairing order, with fresh display names. */
134
+ entries(): RelayPoolEntryView[]
135
+ /** Locate one entry by its state-file record id. */
136
+ getByEntryId(entryId: string): RelayPoolEntryView | undefined
137
+ /** Locate one entry by its (handshake or persisted) server id. */
138
+ getByServerId(serverId: string): RelayPoolEntryView | undefined
139
+ /** Add or re-pair one server: the record lands on disk first, then the
140
+ * live half follows (re-credentialed in place, or a fresh client). */
141
+ pair(input: { serverUrl: string; token: string; deviceId?: string; deviceName?: string }): Promise<
142
+ { ok: true; entry: RelayPoolEntryView; replaced: boolean } | { ok: false; code: 'invalid-url' | 'limit-reached' }
143
+ >
144
+ /** Stop and remove one entry (disk record included). */
145
+ unpair(entryId: string): boolean
146
+ /** Stop everything and empty the paired list; answers the record count. */
147
+ unpairAll(): number
148
+ /** One entry's immediate reconnect, or every `offline` client's. Answers
149
+ * whether any attempt was started. */
150
+ reconnect(entryId?: string): Promise<boolean>
151
+ /** One authenticated ping per `offline` client; the reachable ones get
152
+ * `networkRestored()` (the controlled fast-path back online). */
153
+ probeAndRestoreAll(): Promise<{ reachable: boolean; reconnected: boolean }>
154
+ /** Forward a device rename to every paired server (T59). */
155
+ queueDeviceNameAll(name: string): void
156
+ /** Stop every client and detach the listeners (row teardown). */
157
+ stop(): void
158
+ }
159
+
160
+ /**
161
+ * Build the pool: one legacy migration pass, then one client per persisted
162
+ * record, each with a fire-and-forget startup connect — a failed first dial
163
+ * is the backoff ladder's business, exactly like the single-client wiring
164
+ * before it.
165
+ */
166
+ export function createRelayPool(options: RelayPoolOptions): RelayPool {
167
+ const stateFile = options.stateFile
168
+ const createClientImpl = options.createClient ?? createRelayClient
169
+
170
+ /** Insertion order = pairing order = `listPairedServers` order. */
171
+ const pool = new Map<string, PoolEntry>()
172
+
173
+ /** The handshake id a view should report: live handshake first, then the
174
+ * persisted record (a fresh entry that never reached the server yet). */
175
+ const effectiveServerId = (entry: PoolEntry): string | undefined =>
176
+ entry.client.handshakeInfo?.serverId ?? entry.record.serverId
177
+ const effectiveServerName = (entry: PoolEntry): string | undefined =>
178
+ entry.client.handshakeInfo?.serverName ?? entry.record.serverName
179
+
180
+ const buildView = (entry: PoolEntry, displayName: string): RelayPoolEntryView => ({
181
+ entryId: entry.record.id,
182
+ serverUrl: entry.record.serverUrl,
183
+ serverId: effectiveServerId(entry),
184
+ serverName: effectiveServerName(entry),
185
+ displayName,
186
+ client: entry.client,
187
+ createdAt: entry.record.createdAt,
188
+ })
189
+
190
+ const entries = (): RelayPoolEntryView[] => {
191
+ const list = [...pool.values()]
192
+ const names = deriveDisplayServerNames(
193
+ list.map((entry) => ({ entryId: entry.record.id, serverName: effectiveServerName(entry) ?? '', serverUrl: entry.record.serverUrl })),
194
+ )
195
+ return list.map((entry) => buildView(entry, names.get(entry.record.id) ?? entry.record.serverUrl))
196
+ }
197
+
198
+ /** The client's credential getters read the CURRENT record per request, so
199
+ * a re-pair (an in-place record swap) reaches the next attempt directly. */
200
+ const createClientFor = (record: PairedServerRecord): RelayClient => {
201
+ const liveRecord = (): PairedServerRecord => pool.get(record.id)?.record ?? record
202
+ const clientOptions: CreateRelayClientOptions = {
203
+ getServerUrl: () => liveRecord().serverUrl,
204
+ getToken: () => liveRecord().token,
205
+ computeOwnFingerprints: options.computeOwnFingerprints,
206
+ ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}),
207
+ ...(options.clock !== undefined ? { clock: options.clock } : {}),
208
+ }
209
+ return createClientImpl(clientOptions)
210
+ }
211
+
212
+ /** Fold one completed handshake into the record — on disk (whose dedupe
213
+ * may retire stale same-server entries) and in memory — then stop and
214
+ * announce the retired ones. A non-8-hex server id is not handshake
215
+ * material; skip the whole bookkeeping for it. */
216
+ const handleHandshake = (entry: PoolEntry): void => {
217
+ const info = entry.client.handshakeInfo
218
+ if (info === undefined) return
219
+ const result = recordServerHandshake(stateFile, entry.record.id, {
220
+ serverId: info.serverId,
221
+ serverName: info.serverName,
222
+ ...(info.deviceName !== undefined ? { deviceName: info.deviceName } : {}),
223
+ })
224
+ entry.record = {
225
+ ...entry.record,
226
+ serverId: info.serverId,
227
+ ...(info.serverName !== '' ? { serverName: info.serverName } : {}),
228
+ ...(info.deviceName !== undefined ? { deviceName: info.deviceName } : {}),
229
+ }
230
+ for (const removedId of result.removedEntryIds) {
231
+ const stale = pool.get(removedId)
232
+ if (stale !== undefined) {
233
+ stale.unsubscribe()
234
+ stale.client.stop()
235
+ pool.delete(removedId)
236
+ }
237
+ options.onServerRemoved?.({ entryId: removedId, serverId: info.serverId, reason: 'dedupe' })
238
+ }
239
+ }
240
+
241
+ const attach = (entry: PoolEntry): void => {
242
+ entry.unsubscribe = entry.client.subscribe((state) => {
243
+ if (state === 'online') handleHandshake(entry)
244
+ options.onStateChange?.({
245
+ entryId: entry.record.id,
246
+ serverId: effectiveServerId(entry),
247
+ state,
248
+ ...(entry.client.lastError !== undefined ? { lastError: entry.client.lastError } : {}),
249
+ })
250
+ })
251
+ }
252
+
253
+ const addEntry = (record: PairedServerRecord): void => {
254
+ const entry: PoolEntry = { record, client: createClientFor(record), unsubscribe: () => {} }
255
+ attach(entry)
256
+ pool.set(record.id, entry)
257
+ // Startup dials are fire-and-forget: the failure is the client's own
258
+ // backoff ladder's business, never the wiring's (same as index.ts).
259
+ void entry.client.connect().catch(() => {})
260
+ }
261
+
262
+ // Startup: fold the legacy row credentials in (only when the list is still
263
+ // empty), then one client per record.
264
+ const legacy = options.legacySource?.()
265
+ if (legacy !== undefined) {
266
+ try {
267
+ migrateLegacyClientIntoServers(stateFile, legacy)
268
+ } catch {
269
+ // Best effort — the row keeps its credentials for the next start.
270
+ }
271
+ }
272
+ for (const record of listPairedServers(stateFile)) addEntry(record)
273
+
274
+ const detach = (entry: PoolEntry): void => {
275
+ entry.unsubscribe()
276
+ entry.client.stop()
277
+ pool.delete(entry.record.id)
278
+ }
279
+
280
+ return {
281
+ entries,
282
+
283
+ getByEntryId(entryId) {
284
+ return entries().find((view) => view.entryId === entryId)
285
+ },
286
+
287
+ getByServerId(serverId) {
288
+ return entries().find((view) => view.serverId === serverId)
289
+ },
290
+
291
+ async pair(input) {
292
+ const result = upsertPairedServer(stateFile, input)
293
+ if (!result.ok) return result
294
+ const existing = pool.get(result.record.id)
295
+ if (existing !== undefined) {
296
+ existing.record = result.record
297
+ // The getters read the swapped record per request; this both cancels
298
+ // a pending retry wait and dials the new credentials immediately.
299
+ existing.client.credentialsChanged()
300
+ } else {
301
+ addEntry(result.record)
302
+ }
303
+ // The entry is in the pool by now by construction; entries() also gives
304
+ // it the dedupe-numbered display name the other views would show.
305
+ const view = entries().find((candidate) => candidate.entryId === result.record.id)
306
+ return { ok: true, entry: view as RelayPoolEntryView, replaced: result.replaced }
307
+ },
308
+
309
+ unpair(entryId) {
310
+ const entry = pool.get(entryId)
311
+ const removed = removePairedServer(stateFile, entryId)
312
+ if (entry !== undefined) detach(entry)
313
+ if (removed) {
314
+ options.onServerRemoved?.({
315
+ entryId,
316
+ serverId: entry !== undefined ? effectiveServerId(entry) : undefined,
317
+ reason: 'unpair',
318
+ })
319
+ }
320
+ return removed
321
+ },
322
+
323
+ unpairAll() {
324
+ const records = listPairedServers(stateFile)
325
+ const count = clearPairedServers(stateFile)
326
+ const onDisk = new Set(records.map((record) => record.id))
327
+ for (const record of records) {
328
+ const entry = pool.get(record.id)
329
+ if (entry !== undefined) detach(entry)
330
+ options.onServerRemoved?.({
331
+ entryId: record.id,
332
+ serverId: entry !== undefined ? effectiveServerId(entry) : record.serverId,
333
+ reason: 'unpair',
334
+ })
335
+ }
336
+ // Memory-only stragglers (a record removed underneath us mid-flight):
337
+ // stop them too — unpairAll means ALL — without a second announcement
338
+ // for the ones the disk pass already reported.
339
+ for (const entry of [...pool.values()]) {
340
+ detach(entry)
341
+ if (!onDisk.has(entry.record.id)) {
342
+ options.onServerRemoved?.({ entryId: entry.record.id, serverId: effectiveServerId(entry), reason: 'unpair' })
343
+ }
344
+ }
345
+ return count
346
+ },
347
+
348
+ async reconnect(entryId) {
349
+ if (entryId !== undefined) {
350
+ const entry = pool.get(entryId)
351
+ return entry !== undefined ? entry.client.reconnect() : false
352
+ }
353
+ let any = false
354
+ for (const entry of pool.values()) {
355
+ if (entry.client.state === 'offline') any = entry.client.reconnect() || any
356
+ }
357
+ return any
358
+ },
359
+
360
+ async probeAndRestoreAll() {
361
+ let reachable = false
362
+ let reconnected = false
363
+ for (const entry of pool.values()) {
364
+ if (entry.client.state !== 'offline') continue
365
+ if (!(await entry.client.probeHealth())) continue
366
+ reachable = true
367
+ reconnected = entry.client.networkRestored() || reconnected
368
+ }
369
+ return { reachable, reconnected }
370
+ },
371
+
372
+ queueDeviceNameAll(name) {
373
+ for (const entry of pool.values()) entry.client.queueDeviceName(name)
374
+ },
375
+
376
+ stop() {
377
+ for (const entry of [...pool.values()]) {
378
+ entry.unsubscribe()
379
+ entry.client.stop()
380
+ }
381
+ pool.clear()
382
+ },
383
+ }
384
+ }
385
+
386
+ /** Re-exported so the pool's consumers never import state-store for the cap. */
387
+ export { MAX_PAIRED_SERVERS }