dsh-zen-remote 2.3.11 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/CHANGELOG.md +45 -78
  2. package/README.md +94 -84
  3. package/README.zh-CN.md +90 -81
  4. package/bin/zen-remote.mjs +5 -0
  5. package/docs/interface.md +6 -6
  6. package/docs/remote-access.en.md +6 -4
  7. package/docs/remote-access.md +6 -4
  8. package/dsh-push.mjs +96 -10
  9. package/lib/adapters/dsh-better-sidebar/index.js +7 -3
  10. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  11. package/lib/admin-routes.js +10 -2
  12. package/lib/admin-routes.js.map +1 -1
  13. package/lib/cli/args.js +251 -0
  14. package/lib/cli/args.js.map +1 -0
  15. package/lib/cli/bootstrap.js +449 -0
  16. package/lib/cli/bootstrap.js.map +1 -0
  17. package/lib/cli/command-setup.js +484 -0
  18. package/lib/cli/command-setup.js.map +1 -0
  19. package/lib/cli/commands-client.js +359 -0
  20. package/lib/cli/commands-client.js.map +1 -0
  21. package/lib/cli/commands-host.js +1108 -0
  22. package/lib/cli/commands-host.js.map +1 -0
  23. package/lib/cli/commands-status.js +279 -0
  24. package/lib/cli/commands-status.js.map +1 -0
  25. package/lib/cli/config-writer.js +334 -0
  26. package/lib/cli/config-writer.js.map +1 -0
  27. package/lib/cli/context.js +10 -0
  28. package/lib/cli/context.js.map +1 -0
  29. package/lib/cli/i18n.js +620 -0
  30. package/lib/cli/i18n.js.map +1 -0
  31. package/lib/cli/ipc-client.js +160 -0
  32. package/lib/cli/ipc-client.js.map +1 -0
  33. package/lib/cli/ipc-server.js +464 -0
  34. package/lib/cli/ipc-server.js.map +1 -0
  35. package/lib/cli/main.js +245 -0
  36. package/lib/cli/main.js.map +1 -0
  37. package/lib/cli/qr-terminal.js +48 -0
  38. package/lib/cli/qr-terminal.js.map +1 -0
  39. package/lib/client-data/qr.js +286 -0
  40. package/lib/client-data/qr.js.map +1 -0
  41. package/lib/client-routes.js +308 -25
  42. package/lib/client-routes.js.map +1 -1
  43. package/lib/client.js +1013 -229
  44. package/lib/config.js +50 -12
  45. package/lib/config.js.map +1 -1
  46. package/lib/debug-report.js +28 -1
  47. package/lib/debug-report.js.map +1 -1
  48. package/lib/fetch-route-intercept.js +334 -26
  49. package/lib/fetch-route-intercept.js.map +1 -1
  50. package/lib/index.js +608 -375
  51. package/lib/index.js.map +1 -1
  52. package/lib/intercept.js +1833 -280
  53. package/lib/intercept.js.map +1 -1
  54. package/lib/lan-gate-server.cjs +36 -10
  55. package/lib/merge-streams.js +103 -11
  56. package/lib/merge-streams.js.map +1 -1
  57. package/lib/plugin-route-intercept.js +93 -63
  58. package/lib/plugin-route-intercept.js.map +1 -1
  59. package/lib/relay-access.js +8 -1
  60. package/lib/relay-access.js.map +1 -1
  61. package/lib/relay-client.js +133 -4
  62. package/lib/relay-client.js.map +1 -1
  63. package/lib/relay-pool.js +275 -0
  64. package/lib/relay-pool.js.map +1 -0
  65. package/lib/relay-server.js +269 -17
  66. package/lib/relay-server.js.map +1 -1
  67. package/lib/restart-fields.js +6 -4
  68. package/lib/restart-fields.js.map +1 -1
  69. package/lib/share-ops.js +68 -0
  70. package/lib/share-ops.js.map +1 -1
  71. package/lib/share-store.js +65 -27
  72. package/lib/share-store.js.map +1 -1
  73. package/lib/state-store.js +400 -0
  74. package/lib/state-store.js.map +1 -0
  75. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
  76. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  77. package/lib/types/admin-routes.d.ts.map +1 -1
  78. package/lib/types/cli/args.d.ts +78 -0
  79. package/lib/types/cli/args.d.ts.map +1 -0
  80. package/lib/types/cli/bootstrap.d.ts +170 -0
  81. package/lib/types/cli/bootstrap.d.ts.map +1 -0
  82. package/lib/types/cli/command-setup.d.ts +72 -0
  83. package/lib/types/cli/command-setup.d.ts.map +1 -0
  84. package/lib/types/cli/commands-client.d.ts +36 -0
  85. package/lib/types/cli/commands-client.d.ts.map +1 -0
  86. package/lib/types/cli/commands-host.d.ts +82 -0
  87. package/lib/types/cli/commands-host.d.ts.map +1 -0
  88. package/lib/types/cli/commands-status.d.ts +46 -0
  89. package/lib/types/cli/commands-status.d.ts.map +1 -0
  90. package/lib/types/cli/config-writer.d.ts +92 -0
  91. package/lib/types/cli/config-writer.d.ts.map +1 -0
  92. package/lib/types/cli/context.d.ts +39 -0
  93. package/lib/types/cli/context.d.ts.map +1 -0
  94. package/lib/types/cli/i18n.d.ts +32 -0
  95. package/lib/types/cli/i18n.d.ts.map +1 -0
  96. package/lib/types/cli/ipc-client.d.ts +83 -0
  97. package/lib/types/cli/ipc-client.d.ts.map +1 -0
  98. package/lib/types/cli/ipc-server.d.ts +113 -0
  99. package/lib/types/cli/ipc-server.d.ts.map +1 -0
  100. package/lib/types/cli/main.d.ts +57 -0
  101. package/lib/types/cli/main.d.ts.map +1 -0
  102. package/lib/types/cli/qr-terminal.d.ts +17 -0
  103. package/lib/types/cli/qr-terminal.d.ts.map +1 -0
  104. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
  105. package/lib/types/client/current-session-report.d.ts +4 -0
  106. package/lib/types/client/current-session-report.d.ts.map +1 -1
  107. package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
  108. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  109. package/lib/types/client/locales.d.ts +47 -3
  110. package/lib/types/client/locales.d.ts.map +1 -1
  111. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  112. package/lib/types/client/settings/settings-css.d.ts +1 -1
  113. package/lib/types/client/settings/settings-css.d.ts.map +1 -1
  114. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  115. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  116. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  117. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
  118. package/lib/types/client/wizard/wizard-css.d.ts +1 -1
  119. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
  120. package/lib/types/client-data/model-group-side.d.ts +23 -2
  121. package/lib/types/client-data/model-group-side.d.ts.map +1 -1
  122. package/lib/types/client-data/remote-status.d.ts +47 -1
  123. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  124. package/lib/types/client-data/settings-form.d.ts +85 -1
  125. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  126. package/lib/types/client-data/wizard.d.ts +56 -1
  127. package/lib/types/client-data/wizard.d.ts.map +1 -1
  128. package/lib/types/client-routes.d.ts +25 -1
  129. package/lib/types/client-routes.d.ts.map +1 -1
  130. package/lib/types/config.d.ts +40 -14
  131. package/lib/types/config.d.ts.map +1 -1
  132. package/lib/types/debug-report.d.ts.map +1 -1
  133. package/lib/types/fetch-route-intercept.d.ts +66 -8
  134. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  135. package/lib/types/index.d.ts +9 -2
  136. package/lib/types/index.d.ts.map +1 -1
  137. package/lib/types/intercept.d.ts +72 -10
  138. package/lib/types/intercept.d.ts.map +1 -1
  139. package/lib/types/merge-streams.d.ts +79 -1
  140. package/lib/types/merge-streams.d.ts.map +1 -1
  141. package/lib/types/plugin-route-intercept.d.ts +25 -9
  142. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  143. package/lib/types/relay-access.d.ts +3 -1
  144. package/lib/types/relay-access.d.ts.map +1 -1
  145. package/lib/types/relay-client.d.ts +23 -2
  146. package/lib/types/relay-client.d.ts.map +1 -1
  147. package/lib/types/relay-pool.d.ts +148 -0
  148. package/lib/types/relay-pool.d.ts.map +1 -0
  149. package/lib/types/relay-server.d.ts +30 -3
  150. package/lib/types/relay-server.d.ts.map +1 -1
  151. package/lib/types/restart-fields.d.ts +5 -5
  152. package/lib/types/restart-fields.d.ts.map +1 -1
  153. package/lib/types/share-ops.d.ts +47 -1
  154. package/lib/types/share-ops.d.ts.map +1 -1
  155. package/lib/types/share-store.d.ts.map +1 -1
  156. package/lib/types/state-store.d.ts +179 -0
  157. package/lib/types/state-store.d.ts.map +1 -0
  158. package/lib/types/upload-limits.d.ts +15 -0
  159. package/lib/types/upload-limits.d.ts.map +1 -1
  160. package/lib/types/virtual-id.d.ts +21 -0
  161. package/lib/types/virtual-id.d.ts.map +1 -1
  162. package/lib/types/wizard/probe.d.ts.map +1 -1
  163. package/lib/types/wizard/proxy-snippets.d.ts +3 -0
  164. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  165. package/lib/types/wizard/routes.d.ts +1 -0
  166. package/lib/types/wizard/routes.d.ts.map +1 -1
  167. package/lib/types/wizard/status.d.ts +9 -0
  168. package/lib/types/wizard/status.d.ts.map +1 -1
  169. package/lib/types/wizard/verify.d.ts +3 -0
  170. package/lib/types/wizard/verify.d.ts.map +1 -1
  171. package/lib/upload-limits.js +15 -0
  172. package/lib/upload-limits.js.map +1 -1
  173. package/lib/virtual-id.js +60 -0
  174. package/lib/virtual-id.js.map +1 -1
  175. package/lib/wizard/probe.js +46 -1
  176. package/lib/wizard/probe.js.map +1 -1
  177. package/lib/wizard/proxy-snippets.js +14 -1
  178. package/lib/wizard/proxy-snippets.js.map +1 -1
  179. package/lib/wizard/routes.js +23 -4
  180. package/lib/wizard/routes.js.map +1 -1
  181. package/lib/wizard/status.js +21 -0
  182. package/lib/wizard/status.js.map +1 -1
  183. package/lib/wizard/verify.js +31 -8
  184. package/lib/wizard/verify.js.map +1 -1
  185. package/package.json +8 -4
  186. package/pwa/icons/badge-96.png +0 -0
  187. package/pwa/icons/badge.svg +1 -0
  188. package/pwa/manifest.json +2 -1
  189. package/pwa/sw.js +3 -1
  190. package/scripts/check-debug-report.mjs +41 -1
  191. package/scripts/check-settings-form.mjs +14 -3
  192. package/scripts/check-settings-view.mjs +247 -0
  193. package/scripts/check-wizard-ui.mjs +37 -0
  194. package/src/adapters/dsh-better-sidebar/index.ts +7 -3
  195. package/src/admin-routes.ts +10 -2
  196. package/src/cli/args.ts +289 -0
  197. package/src/cli/bootstrap.ts +569 -0
  198. package/src/cli/command-setup.ts +540 -0
  199. package/src/cli/commands-client.ts +369 -0
  200. package/src/cli/commands-host.ts +1127 -0
  201. package/src/cli/commands-status.ts +296 -0
  202. package/src/cli/config-writer.ts +354 -0
  203. package/src/cli/context.ts +38 -0
  204. package/src/cli/i18n.ts +645 -0
  205. package/src/cli/ipc-client.ts +216 -0
  206. package/src/cli/ipc-server.ts +531 -0
  207. package/src/cli/main.ts +285 -0
  208. package/src/cli/qr-terminal.ts +49 -0
  209. package/src/client/RemoteStatusIcon.tsx +4 -1
  210. package/src/client/current-session-report.ts +28 -4
  211. package/src/client/effects/model-group-side.ts +19 -5
  212. package/src/client/effects/remote-session.ts +26 -6
  213. package/src/client/locales.ts +96 -6
  214. package/src/client/settings/SettingsSection.tsx +287 -92
  215. package/src/client/settings/settings-css.ts +18 -0
  216. package/src/client/styles/remote-session.css.ts +15 -1
  217. package/src/client/wizard/GatewayWizard.tsx +470 -109
  218. package/src/client/wizard/cards/ProxyCard.tsx +2 -1
  219. package/src/client/wizard/wizard-css.ts +23 -0
  220. package/src/client-data/model-group-side.ts +37 -10
  221. package/src/client-data/remote-status.ts +112 -17
  222. package/src/client-data/settings-form.ts +164 -8
  223. package/src/client-data/wizard.ts +112 -2
  224. package/src/client-routes.ts +334 -26
  225. package/src/config.ts +62 -16
  226. package/src/debug-report.ts +27 -1
  227. package/src/fetch-route-intercept.ts +381 -32
  228. package/src/index.ts +608 -380
  229. package/src/intercept.ts +1866 -286
  230. package/src/merge-streams.ts +155 -10
  231. package/src/plugin-route-intercept.ts +120 -71
  232. package/src/relay-access.ts +14 -3
  233. package/src/relay-client.ts +163 -5
  234. package/src/relay-pool.ts +387 -0
  235. package/src/relay-server.ts +312 -16
  236. package/src/restart-fields.ts +6 -4
  237. package/src/share-ops.ts +82 -1
  238. package/src/share-store.ts +59 -26
  239. package/src/state-store.ts +428 -0
  240. package/src/upload-limits.ts +16 -0
  241. package/src/virtual-id.ts +57 -0
  242. package/src/wizard/probe.ts +54 -2
  243. package/src/wizard/proxy-snippets.ts +17 -2
  244. package/src/wizard/routes.ts +24 -5
  245. package/src/wizard/status.ts +28 -0
  246. package/src/wizard/verify.ts +33 -11
  247. package/AGENTS.md +0 -185
  248. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
  249. package/docs/adr/0002-relay-emulates-websocket.md +0 -8
@@ -0,0 +1,428 @@
1
+ /**
2
+ * The three-in-one state file (MS-1): `<DSH_HOME>/zen-remote-state.json`
3
+ * holds the server's own identity (`serverId`), the shared-session table
4
+ * (`sessions`) and the list of paired relay servers (`servers`) in ONE file,
5
+ * so the three writers — relay-server.ts's loadServerId, share-store.ts's
6
+ * persist, and the pairing operations below — can never clobber each other's
7
+ * top-level fields. Every write goes through {@link writeStateFile}, which
8
+ * reads the CURRENT file first and hands it to the updater: a share-table
9
+ * write preserves `serverId` and `servers`, an identity write preserves the
10
+ * table and the list.
11
+ *
12
+ * The file is 0600 on purpose: `servers` rows carry pairing tokens. The write
13
+ * is the same atomic tmp+rename dance the share table has always used, with
14
+ * an explicit chmod in front of the rename (writeFileSync's `mode` only
15
+ * applies at creation — a leftover `.tmp` from a crashed run would keep its
16
+ * old mode).
17
+ *
18
+ * Migration (MS-1): the pre-multi-server layout kept three separate files —
19
+ * `zen-remote-server.json` (the identity) and `zen-remote-shares.json` (the
20
+ * table) — and the plugin row carried the one paired server's address+token.
21
+ * {@link ensureStateMigrated} folds all three into the state file exactly
22
+ * once, deleting a legacy file only after its data landed on disk. The
23
+ * migration parts are also exported singly: relay-server.ts runs the identity
24
+ * one at its `loadServerId` entry and share-store.ts runs the table one at
25
+ * its startup load, so each writer is correct even when nothing else called
26
+ * the combined migration first.
27
+ *
28
+ * Pure module discipline: no clocks of its own (`Date.now` at the two call
29
+ * sites that need a timestamp), every read tolerant, nothing throws out of
30
+ * the migration helpers — a damaged file degrades to an empty state, never a
31
+ * dead plugin.
32
+ */
33
+
34
+ import { randomBytes } from 'node:crypto'
35
+ import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
36
+ import { dirname, join } from 'node:path'
37
+ import { normalizeServerUrl } from './client-pairing.js'
38
+
39
+ /** The one state file this module owns, always directly under DSH_HOME. */
40
+ export const STATE_FILE_NAME = 'zen-remote-state.json'
41
+
42
+ /** Pre-multi-server identity file, migrated into the state file and deleted. */
43
+ export const LEGACY_SERVER_ID_FILE = 'zen-remote-server.json'
44
+
45
+ /** Pre-multi-server share-table file, migrated into the state file and deleted. */
46
+ export const LEGACY_SHARES_FILE = 'zen-remote-shares.json'
47
+
48
+ /** How many relay servers one sub-client may keep paired at once (MS-2). */
49
+ export const MAX_PAIRED_SERVERS = 8
50
+
51
+ /** The 8-hex short server id shape (`randomBytes(4).toString('hex')`). */
52
+ const SERVER_ID_PATTERN = /^[0-9a-f]{8}$/
53
+
54
+ /** One row of the persisted share table (verbatim from share-store.ts). */
55
+ export interface PersistedShareRow {
56
+ sharedAt: number
57
+ lastActivityAt: number
58
+ }
59
+
60
+ export interface PairedServerRecord {
61
+ /** Local stable entry id (8-hex, `randomBytes(4).toString('hex')`). */
62
+ id: string
63
+ /** The server address, normalized by `normalizeServerUrl`. */
64
+ serverUrl: string
65
+ /** The device pairing token — sensitive, 0600 on disk, NEVER echoed into
66
+ * any state response that leaves the process. */
67
+ token: string
68
+ /** The server's 8-hex id as of the last completed handshake; unset until
69
+ * the first one succeeds. */
70
+ serverId?: string
71
+ /** The server's display name as of the last completed handshake. */
72
+ serverName?: string
73
+ /** This device's id on that server, as the pairing registered it. */
74
+ deviceId?: string
75
+ /** This device's name in that server's table, as last confirmed. */
76
+ deviceName?: string
77
+ /** When the pairing was first added (epoch ms) — decides the merged
78
+ * sidebar order and the -1/-2 numbering of duplicate display names. */
79
+ createdAt: number
80
+ }
81
+
82
+ export interface ZenRemoteStateFile {
83
+ version: 1
84
+ serverId?: string
85
+ sessions?: Record<string, PersistedShareRow>
86
+ servers?: PairedServerRecord[]
87
+ }
88
+
89
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
90
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
91
+ }
92
+
93
+ function isFiniteNumber(value: unknown): value is number {
94
+ return typeof value === 'number' && Number.isFinite(value)
95
+ }
96
+
97
+ /** The one legal shape of a persisted server id (relay-server.ts mints it). */
98
+ export function isLegalServerId(value: unknown): value is string {
99
+ return typeof value === 'string' && SERVER_ID_PATTERN.test(value)
100
+ }
101
+
102
+ /**
103
+ * Read and parse the state file. A missing file is the normal first run; a
104
+ * damaged file reads as empty — quarantined as `<file>.corrupt-<now>` UNLESS
105
+ * it still carries a migration source (a legal `serverId`, the one shape the
106
+ * migration paths know how to rescue): renaming that away would silently
107
+ * reset the server identity on the next load, so the file stays put and the
108
+ * salvageable field rides along in the returned state.
109
+ * Never throws.
110
+ */
111
+ export function readStateFile(filePath: string): ZenRemoteStateFile {
112
+ let raw: string | null = null
113
+ try {
114
+ raw = readFileSync(filePath, 'utf8')
115
+ } catch {
116
+ return { version: 1 }
117
+ }
118
+ let parsed: unknown
119
+ try {
120
+ parsed = JSON.parse(raw)
121
+ } catch {
122
+ parsed = undefined
123
+ }
124
+ if (isPlainObject(parsed) && parsed.version === 1) {
125
+ const state: ZenRemoteStateFile = { version: 1 }
126
+ if (isLegalServerId(parsed.serverId)) state.serverId = parsed.serverId
127
+ // Row-level validity is each consumer's business (share-store validates
128
+ // rows, listPairedServers validates records) — a legal container with a
129
+ // bad field just drops the field here.
130
+ if (isPlainObject(parsed.sessions)) state.sessions = parsed.sessions as Record<string, PersistedShareRow>
131
+ if (Array.isArray(parsed.servers)) state.servers = parsed.servers as PairedServerRecord[]
132
+ return state
133
+ }
134
+ // Not a legal version:1 container. Salvage a bare legal serverId (the
135
+ // identity is the one thing a shape-less state file can still be carrying);
136
+ // anything else gets quarantined with its content preserved.
137
+ if (isPlainObject(parsed) && isLegalServerId(parsed.serverId)) {
138
+ return { version: 1, serverId: parsed.serverId }
139
+ }
140
+ try {
141
+ renameSync(filePath, `${filePath}.corrupt-${Date.now()}`)
142
+ } catch {
143
+ // Keep the empty state either way; startup must not fail here.
144
+ }
145
+ return { version: 1 }
146
+ }
147
+
148
+ /**
149
+ * The one write door: read the CURRENT file, let `updater` produce the next
150
+ * state from it, then atomically replace the file (tmp at 0600, best-effort
151
+ * chmod, rename). Belt AND braces: the braces are the updaters in this module
152
+ * spreading `current` explicitly; the belt is the merge below — an updater
153
+ * that names only the field it owns must never erase the top-level fields it
154
+ * did not name (the partial-updater shape caught in the MS-phase1 review). A
155
+ * field the updater explicitly sets to `undefined` still goes away, so
156
+ * removal stays expressible. The result is normalized to the four known
157
+ * top-level fields before it reaches disk. Throws on I/O failure; callers
158
+ * decide what that means (share-store records it, loadServerId degrades, the
159
+ * migration helpers swallow it).
160
+ */
161
+ export function writeStateFile(filePath: string, updater: (current: ZenRemoteStateFile) => ZenRemoteStateFile): void {
162
+ const current = readStateFile(filePath)
163
+ const next = updater(current)
164
+ const safe: ZenRemoteStateFile = isPlainObject(next) ? (next as ZenRemoteStateFile) : { version: 1 }
165
+ const merged = { ...current, ...safe, version: 1 as const }
166
+ const out: ZenRemoteStateFile = { version: 1 }
167
+ if (isLegalServerId(merged.serverId)) out.serverId = merged.serverId
168
+ if (isPlainObject(merged.sessions)) out.sessions = merged.sessions
169
+ if (Array.isArray(merged.servers)) out.servers = merged.servers
170
+ mkdirSync(dirname(filePath), { recursive: true })
171
+ writeFileSync(`${filePath}.tmp`, JSON.stringify(out), { mode: 0o600 })
172
+ // writeFileSync's mode only applies at creation; a leftover tmp from a
173
+ // crashed run keeps its old mode, so the rename must not trust it.
174
+ try {
175
+ chmodSync(`${filePath}.tmp`, 0o600)
176
+ } catch {
177
+ // Best effort — the fresh-file mode above already covers the common path.
178
+ }
179
+ renameSync(`${filePath}.tmp`, filePath)
180
+ }
181
+
182
+ /**
183
+ * Move a legacy `zen-remote-server.json` identity into the state file — but
184
+ * only when the state file has no legal identity of its own yet. The legacy
185
+ * file is deleted only after the write succeeded; a failed write leaves both
186
+ * files alone so the next start can try again. Never throws.
187
+ */
188
+ export function ensureServerIdMigrated(stateFile: string): void {
189
+ try {
190
+ const state = readStateFile(stateFile)
191
+ if (state.serverId !== undefined) return
192
+ const legacyFile = join(dirname(stateFile), LEGACY_SERVER_ID_FILE)
193
+ let legacyId: string | undefined
194
+ try {
195
+ const parsed: unknown = JSON.parse(readFileSync(legacyFile, 'utf8'))
196
+ const id = (parsed as { serverId?: unknown } | null)?.serverId
197
+ if (typeof id === 'string' && SERVER_ID_PATTERN.test(id)) legacyId = id
198
+ } catch {
199
+ return
200
+ }
201
+ if (legacyId === undefined) return
202
+ writeStateFile(stateFile, (current) => ({ ...current, version: 1, serverId: legacyId }))
203
+ try {
204
+ unlinkSync(legacyFile)
205
+ } catch {
206
+ // Already gone is fine — the data is in the state file now.
207
+ }
208
+ } catch {
209
+ // Best effort: an unwritable home keeps its legacy files for next time.
210
+ }
211
+ }
212
+
213
+ /** Share-store's row validity, mirrored here so migrated rows load cleanly. */
214
+ const MAX_ID_LENGTH = 200
215
+
216
+ /**
217
+ * Move a legacy `zen-remote-shares.json` table into the state file — but only
218
+ * when the state file carries no sessions yet. Legal rows only; the legacy
219
+ * file is deleted only after the write succeeded. Never throws.
220
+ */
221
+ export function ensureSharesMigrated(stateFile: string): void {
222
+ try {
223
+ const state = readStateFile(stateFile)
224
+ if (state.sessions !== undefined) return
225
+ const legacyFile = join(dirname(stateFile), LEGACY_SHARES_FILE)
226
+ let rows: Record<string, PersistedShareRow> | undefined
227
+ try {
228
+ const parsed: unknown = JSON.parse(readFileSync(legacyFile, 'utf8'))
229
+ if (isPlainObject(parsed) && parsed.version === 1 && isPlainObject(parsed.sessions)) {
230
+ const out: Record<string, PersistedShareRow> = Object.create(null)
231
+ for (const [id, value] of Object.entries(parsed.sessions)) {
232
+ if (typeof id !== 'string' || id === '' || id.length > MAX_ID_LENGTH || !isPlainObject(value)) continue
233
+ const { sharedAt, lastActivityAt } = value
234
+ if (!isFiniteNumber(sharedAt) || !isFiniteNumber(lastActivityAt)) continue
235
+ out[id] = { sharedAt, lastActivityAt }
236
+ }
237
+ rows = out
238
+ }
239
+ } catch {
240
+ return
241
+ }
242
+ if (rows === undefined) return
243
+ writeStateFile(stateFile, (current) => ({ ...current, version: 1, sessions: rows }))
244
+ try {
245
+ unlinkSync(legacyFile)
246
+ } catch {
247
+ // Already gone is fine — the data is in the state file now.
248
+ }
249
+ } catch {
250
+ // Best effort: an unwritable home keeps its legacy files for next time.
251
+ }
252
+ }
253
+
254
+ function newPairedServerRecord(serverUrl: string, token: string, now?: number, deviceId?: string, deviceName?: string): PairedServerRecord {
255
+ return {
256
+ id: randomBytes(4).toString('hex'),
257
+ serverUrl,
258
+ token,
259
+ createdAt: now ?? Date.now(),
260
+ ...(deviceId !== undefined ? { deviceId } : {}),
261
+ ...(deviceName !== undefined ? { deviceName } : {}),
262
+ }
263
+ }
264
+
265
+ /** Validate one raw persisted record; anything hostile drops out of the list. */
266
+ function normalizeRecord(value: unknown): PairedServerRecord | undefined {
267
+ if (!isPlainObject(value)) return undefined
268
+ const { id, serverUrl, token, createdAt } = value
269
+ if (typeof id !== 'string' || id === '' || id.length > 64) return undefined
270
+ if (typeof serverUrl !== 'string' || serverUrl === '') return undefined
271
+ if (typeof token !== 'string') return undefined
272
+ if (!isFiniteNumber(createdAt)) return undefined
273
+ const record: PairedServerRecord = { id, serverUrl, token, createdAt }
274
+ if (isLegalServerId(value.serverId)) record.serverId = value.serverId
275
+ if (typeof value.serverName === 'string' && value.serverName !== '') record.serverName = value.serverName
276
+ if (typeof value.deviceId === 'string' && value.deviceId !== '') record.deviceId = value.deviceId
277
+ if (typeof value.deviceName === 'string' && value.deviceName !== '') record.deviceName = value.deviceName
278
+ return record
279
+ }
280
+
281
+ /**
282
+ * The paired-servers list, in file (pairing) order, as validated copies.
283
+ * Invalid rows are skipped — one hostile record must not hide the rest.
284
+ */
285
+ export function listPairedServers(filePath: string): PairedServerRecord[] {
286
+ const state = readStateFile(filePath)
287
+ if (!Array.isArray(state.servers)) return []
288
+ const records: PairedServerRecord[] = []
289
+ for (const value of state.servers) {
290
+ const record = normalizeRecord(value)
291
+ if (record !== undefined) records.push(record)
292
+ }
293
+ return records
294
+ }
295
+
296
+ /** What {@link upsertPairedServer} answers. */
297
+ export type UpsertPairingResult =
298
+ | { ok: true; record: PairedServerRecord; replaced: boolean }
299
+ | { ok: false; code: 'invalid-url' | 'limit-reached' }
300
+
301
+ /**
302
+ * Add or re-pair one server. A NEW address appends (refused past
303
+ * {@link MAX_PAIRED_SERVERS}); an EXISTING address updates in place — same
304
+ * entry id, same position, same `createdAt`, fresh token and device fields —
305
+ * which is why the cap never blocks a re-pair.
306
+ */
307
+ export function upsertPairedServer(
308
+ filePath: string,
309
+ input: { serverUrl: string; token: string; deviceId?: string; deviceName?: string; now?: number },
310
+ ): UpsertPairingResult {
311
+ const normalized = normalizeServerUrl(input.serverUrl)
312
+ if (!normalized.ok) return { ok: false, code: 'invalid-url' }
313
+ const deviceId = typeof input.deviceId === 'string' && input.deviceId !== '' ? input.deviceId : undefined
314
+ const deviceName = typeof input.deviceName === 'string' && input.deviceName !== '' ? input.deviceName : undefined
315
+ const token = typeof input.token === 'string' ? input.token : ''
316
+ const records = listPairedServers(filePath)
317
+ const existingIndex = records.findIndex((record) => record.serverUrl === normalized.url)
318
+ if (existingIndex !== -1) {
319
+ const updated: PairedServerRecord = {
320
+ ...records[existingIndex],
321
+ token,
322
+ ...(deviceId !== undefined ? { deviceId } : {}),
323
+ ...(deviceName !== undefined ? { deviceName } : {}),
324
+ }
325
+ const nextServers = [...records]
326
+ nextServers[existingIndex] = updated
327
+ writeStateFile(filePath, (current) => ({ ...current, version: 1, servers: nextServers }))
328
+ return { ok: true, record: updated, replaced: true }
329
+ }
330
+ if (records.length >= MAX_PAIRED_SERVERS) return { ok: false, code: 'limit-reached' }
331
+ const created = newPairedServerRecord(normalized.url, token, input.now, deviceId, deviceName)
332
+ writeStateFile(filePath, (current) => ({ ...current, version: 1, servers: [...records, created] }))
333
+ return { ok: true, record: created, replaced: false }
334
+ }
335
+
336
+ /**
337
+ * Fold one completed handshake into the record: `serverId`, `serverName` and
338
+ * (when the server speaks it) `deviceName`. The second dedupe layer lives
339
+ * here too: a DIFFERENT entry already carrying the same `serverId` is a stale
340
+ * duplicate (the same server re-paired under a new address) — it is removed
341
+ * from the list and reported in `removedEntryIds`, the handshaking entry
342
+ * kept. A non-8-hex `serverId` is not recorded and dedupes nothing.
343
+ */
344
+ export function recordServerHandshake(
345
+ filePath: string,
346
+ entryId: string,
347
+ handshake: { serverId: string; serverName: string; deviceName?: string },
348
+ ): { removedEntryIds: string[] } {
349
+ if (!isLegalServerId(handshake.serverId)) return { removedEntryIds: [] }
350
+ const records = listPairedServers(filePath)
351
+ if (!records.some((record) => record.id === entryId)) return { removedEntryIds: [] }
352
+ const removed: string[] = []
353
+ const nextServers: PairedServerRecord[] = []
354
+ for (const record of records) {
355
+ if (record.id === entryId) {
356
+ nextServers.push({
357
+ ...record,
358
+ serverId: handshake.serverId,
359
+ ...(typeof handshake.serverName === 'string' && handshake.serverName !== '' ? { serverName: handshake.serverName } : {}),
360
+ ...(typeof handshake.deviceName === 'string' && handshake.deviceName !== '' ? { deviceName: handshake.deviceName } : {}),
361
+ })
362
+ continue
363
+ }
364
+ if (record.serverId === handshake.serverId) {
365
+ removed.push(record.id)
366
+ continue
367
+ }
368
+ nextServers.push(record)
369
+ }
370
+ writeStateFile(filePath, (current) => ({ ...current, version: 1, servers: nextServers }))
371
+ return { removedEntryIds: removed }
372
+ }
373
+
374
+ /** Remove one entry by id; true when it was actually on the list. */
375
+ export function removePairedServer(filePath: string, entryId: string): boolean {
376
+ const records = listPairedServers(filePath)
377
+ const next = records.filter((record) => record.id !== entryId)
378
+ if (next.length === records.length) return false
379
+ writeStateFile(filePath, (current) => ({ ...current, version: 1, servers: next }))
380
+ return true
381
+ }
382
+
383
+ /** Empty the list; answers how many records went away. */
384
+ export function clearPairedServers(filePath: string): number {
385
+ const records = listPairedServers(filePath)
386
+ if (records.length === 0) return 0
387
+ writeStateFile(filePath, (current) => ({ ...current, version: 1, servers: [] }))
388
+ return records.length
389
+ }
390
+
391
+ /**
392
+ * The third migration: the pre-multi-server row carried exactly one server's
393
+ * address and pairing token. When the state file has no paired servers yet,
394
+ * they become `servers[0]`; the address must pass the claim route's
395
+ * normalizer and the token must be a non-empty string, or nothing is written.
396
+ * Runs at most once in practice — a non-empty list is never re-migrated.
397
+ */
398
+ export function migrateLegacyClientIntoServers(
399
+ filePath: string,
400
+ legacyClient: { serverUrl?: unknown; deviceToken?: unknown },
401
+ ): void {
402
+ if (listPairedServers(filePath).length > 0) return
403
+ const serverUrl = typeof legacyClient?.serverUrl === 'string' ? legacyClient.serverUrl : undefined
404
+ const token = typeof legacyClient?.deviceToken === 'string' ? legacyClient.deviceToken : undefined
405
+ if (serverUrl === undefined || token === undefined || token.trim() === '') return
406
+ const normalized = normalizeServerUrl(serverUrl)
407
+ if (!normalized.ok) return
408
+ writeStateFile(filePath, (current) => ({ ...current, version: 1, servers: [newPairedServerRecord(normalized.url, token)] }))
409
+ }
410
+
411
+ /**
412
+ * The combined one-shot migration for a DSH home: identity, share table and
413
+ * (when handed the row's legacy client credentials) the paired-servers seed.
414
+ * Best effort end to end — any failure leaves the files as they are for the
415
+ * next start.
416
+ */
417
+ export function ensureStateMigrated(home: string, legacyClient?: { serverUrl?: unknown; deviceToken?: unknown }): void {
418
+ const stateFile = join(home, STATE_FILE_NAME)
419
+ ensureServerIdMigrated(stateFile)
420
+ ensureSharesMigrated(stateFile)
421
+ if (legacyClient !== undefined) {
422
+ try {
423
+ migrateLegacyClientIntoServers(stateFile, legacyClient)
424
+ } catch {
425
+ // Best effort — the legacy row credentials stay where they are.
426
+ }
427
+ }
428
+ }
@@ -67,3 +67,19 @@ export const DEFAULT_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
67
67
  * like any other oversized read.
68
68
  */
69
69
  export const MAX_INVOKE_BODY_BYTES = 32 * 1024 * 1024
70
+
71
+ /**
72
+ * Cap for ONE forwarded `/api/file` media answer (P55-2): the sub-client's
73
+ * fetch-route wrap pulls a remote session's inline image / media file
74
+ * through `workspaceFiles/readBytes` and answers the browser's native
75
+ * `<img>` request with the bytes. Deliberately TIGHTER than the host's own
76
+ * `maxImageBytes` default (50 MiB, RT dsh-attachment-local
77
+ * DEFAULT_MAX_IMAGE_BYTES): every forwarded byte rides the relay's JSON
78
+ * channel base64-encoded (~1.33×), so 50 MiB would move ~67 MiB through the
79
+ * link — 20 MiB answers everything a reply body realistically embeds and
80
+ * fails fast past it. NOT related to {@link DEFAULT_MAX_UPLOAD_BYTES}
81
+ * (which sizes the phone composer's uploads); the equal value is
82
+ * coincidence, kept as one definition here only so the drift scanner sees
83
+ * every MiB-scale ceiling exactly once.
84
+ */
85
+ export const MAX_FILE_MEDIA_BYTES = 20 * 1024 * 1024
package/src/virtual-id.ts CHANGED
@@ -35,10 +35,67 @@ export const VIRTUAL_ID_PREFIX = 'zr~'
35
35
  */
36
36
  export const VIRTUAL_GROUP_NAME_MARK = '\u2060'
37
37
 
38
+ /** MS-4: the two zero-width code points the serverId rides on inside a
39
+ * virtual group NAME — ZERO WIDTH SPACE encodes bit 0, ZERO WIDTH
40
+ * NON-JOINER bit 1. Invisible everywhere, and neither can appear in a
41
+ * server id's own visible text, so the window between the two marks is
42
+ * unambiguous. */
43
+ const ZERO_BIT = '\u200b'
44
+ const ONE_BIT = '\u200c'
45
+ const SERVER_ID_BITS = 32
46
+
38
47
  /** The server id half: exactly the 8 lowercase hex chars relay-server.ts
39
48
  * mints (`randomBytes(4).toString('hex')`). */
40
49
  const SERVER_ID_PATTERN = /^[0-9a-f]{8}$/
41
50
 
51
+ /**
52
+ * MS-4: the invisible prefix a REMOTE model-menu group heading carries
53
+ * before its server name (merge-streams.ts mergeModelCatalogs). The full
54
+ * form is `${MARK}${32 zero-width bits}${MARK}` — the 32 bits of the
55
+ * 8-hex serverId (MSB first per nibble), ZERO_BIT for 0 and ONE_BIT for 1
56
+ * — so the browser half can tell WHICH server a remote model group came
57
+ * from (effects/model-group-side.ts classifies per-server hiding from
58
+ * heading text alone). A serverId that is not 8 lowercase hex (the format
59
+ * every real handshake carries) degrades to the bare legacy mark: the
60
+ * heading stays a marked remote heading, just without a decodable server.
61
+ */
62
+ export function encodeServerIdZeroWidth(serverId: unknown): string {
63
+ if (typeof serverId !== 'string' || !SERVER_ID_PATTERN.test(serverId)) return VIRTUAL_GROUP_NAME_MARK
64
+ let bits = ''
65
+ for (const nibble of serverId) {
66
+ for (const bit of Number.parseInt(nibble, 16).toString(2).padStart(4, '0')) {
67
+ bits += bit === '0' ? ZERO_BIT : ONE_BIT
68
+ }
69
+ }
70
+ return `${VIRTUAL_GROUP_NAME_MARK}${bits}${VIRTUAL_GROUP_NAME_MARK}`
71
+ }
72
+
73
+ /**
74
+ * MS-4: read the server id back out of one group heading, or `undefined`.
75
+ * Strict like every decoder here: the text must start with the mark, the
76
+ * window right after it must be exactly 32 zero-width bits closed by the
77
+ * terminator mark — anything else (the legacy bare-mark heading, visible
78
+ * text, a short or long bit run, a missing terminator) answers
79
+ * `undefined`, which the classifier reads as "remote, server unknown".
80
+ */
81
+ export function decodeServerIdZeroWidth(headingText: unknown): string | undefined {
82
+ if (typeof headingText !== 'string' || !headingText.startsWith(VIRTUAL_GROUP_NAME_MARK)) return undefined
83
+ const rest = headingText.slice(VIRTUAL_GROUP_NAME_MARK.length)
84
+ if (rest.length < SERVER_ID_BITS + 1 || rest[SERVER_ID_BITS] !== VIRTUAL_GROUP_NAME_MARK) return undefined
85
+ let hex = ''
86
+ for (let i = 0; i < SERVER_ID_BITS; i += 4) {
87
+ let nibble = 0
88
+ for (let bit = 0; bit < 4; bit++) {
89
+ const bitChar = rest[i + bit]
90
+ if (bitChar === ZERO_BIT) nibble = nibble << 1
91
+ else if (bitChar === ONE_BIT) nibble = (nibble << 1) | 1
92
+ else return undefined
93
+ }
94
+ hex += nibble.toString(16)
95
+ }
96
+ return hex
97
+ }
98
+
42
99
  /** The two halves {@link fromVirtual} recovers from a virtual id. */
43
100
  export interface VirtualIdParts {
44
101
  serverId: string
@@ -14,6 +14,8 @@
14
14
  * private-network allowances) and a 10 s ceiling per request.
15
15
  */
16
16
 
17
+ import http from 'node:http'
18
+ import https from 'node:https'
17
19
  import { normalizeServerUrl } from '../client-pairing.js'
18
20
 
19
21
  /** What answered on `GET <publicUrl>/`. */
@@ -106,11 +108,61 @@ export interface ProbeOptions {
106
108
  * the caller is an XHR/API client and makes `GET /` answer 401 JSON
107
109
  * `{"ok":false,"reason":"unpaired"}` instead of the HTML pairing page;
108
110
  * `/lan-gate/admin` answers 403 JSON regardless of `Accept`). */
111
+ async function probeOnceDirect(
112
+ url: string,
113
+ timeoutMs: number,
114
+ ): Promise<{ status: number; contentType: string; body: string }> {
115
+ const parsed = new URL(url)
116
+ const mod = parsed.protocol === 'https:' ? https : http
117
+ return await new Promise((resolve, reject) => {
118
+ const req = mod.request(
119
+ parsed,
120
+ {
121
+ method: 'GET',
122
+ headers: { Accept: 'text/html' },
123
+ agent: false,
124
+ },
125
+ (res) => {
126
+ const chunks: Buffer[] = []
127
+ res.on('data', (chunk: Buffer | Uint8Array) => {
128
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
129
+ })
130
+ res.on('end', () => {
131
+ clearTimeout(timer)
132
+ resolve({
133
+ status: res.statusCode ?? 0,
134
+ contentType: String(res.headers['content-type'] ?? ''),
135
+ body: Buffer.concat(chunks).toString('utf8'),
136
+ })
137
+ })
138
+ res.on('error', (err) => {
139
+ clearTimeout(timer)
140
+ reject(err)
141
+ })
142
+ },
143
+ )
144
+ const timer = setTimeout(() => {
145
+ const timeoutError = new Error('The operation was aborted due to timeout')
146
+ timeoutError.name = 'TimeoutError'
147
+ req.destroy(timeoutError)
148
+ }, timeoutMs)
149
+ if (typeof timer.unref === 'function') timer.unref()
150
+ req.on('error', (err) => {
151
+ clearTimeout(timer)
152
+ reject(err)
153
+ })
154
+ req.end()
155
+ })
156
+ }
157
+
109
158
  async function probeOnce(
110
- fetchImpl: typeof fetch,
159
+ fetchImpl: typeof fetch | undefined,
111
160
  url: string,
112
161
  timeoutMs: number,
113
162
  ): Promise<{ status: number; contentType: string; body: string }> {
163
+ if (fetchImpl === undefined) {
164
+ return await probeOnceDirect(url, timeoutMs)
165
+ }
114
166
  const response = await fetchImpl(url, {
115
167
  redirect: 'manual',
116
168
  signal: AbortSignal.timeout(timeoutMs),
@@ -136,7 +188,7 @@ export async function probeHostPath(publicUrl: unknown, options: ProbeOptions =
136
188
  if (!normalized.ok) {
137
189
  return { kind: 'unreachable', reason: normalized.reason === 'insecure-http' ? 'insecure-http' : 'invalid-url', ms: 0 }
138
190
  }
139
- const fetchImpl = options.fetchImpl ?? fetch
191
+ const fetchImpl = options.fetchImpl
140
192
  const timeoutMs = options.timeoutMs ?? 10_000
141
193
  try {
142
194
  const [root, admin] = await Promise.all([
@@ -26,6 +26,8 @@ export interface SnippetInput {
26
26
  upstreamHost: unknown
27
27
  /** The gateway's ACTUAL port. */
28
28
  upstreamPort: unknown
29
+ /** Language of the header comment (`'zh'` default, `'en'` English). */
30
+ lang?: 'zh' | 'en' | undefined
29
31
  }
30
32
 
31
33
  /** Integers in [1, 65535] only. */
@@ -56,8 +58,11 @@ export function nginxSnippet(input: SnippetInput): SnippetResult {
56
58
  const parts = validatedParts(input)
57
59
  if ('error' in parts) return parts.error
58
60
  const { domain, upstream, port } = parts
61
+ const headerComment = input.lang === 'en'
62
+ ? '# Place this in the http {} block (skip if you already have this map)'
63
+ : '# 这段放在 http {} 层(若已有同名 map 则跳过)'
59
64
  const text = [
60
- '# 这段放在 http {} 层(若已有同名 map 则跳过)',
65
+ headerComment,
61
66
  'map $http_upgrade $connection_upgrade {',
62
67
  ' default upgrade;',
63
68
  " '' close;",
@@ -113,11 +118,21 @@ export type StepsResult =
113
118
  * flip the catch-all switch. Text only — Lucky has no config file format
114
119
  * to generate.
115
120
  */
116
- export function luckySteps(input: { upstreamHost: unknown; upstreamPort: unknown }): StepsResult {
121
+ export function luckySteps(input: { upstreamHost: unknown; upstreamPort: unknown; lang?: 'zh' | 'en' | undefined }): StepsResult {
117
122
  const upstream = hostOf(input.upstreamHost)
118
123
  if (upstream === undefined) return { ok: false, error: 'bad-upstream-host' }
119
124
  const port = portOf(input.upstreamPort)
120
125
  if (port === undefined) return { ok: false, error: 'bad-upstream-port' }
126
+ if (input.lang === 'en') {
127
+ return {
128
+ ok: true,
129
+ steps: [
130
+ 'In Lucky "Web Service", create a new rule listening on port 443 and bind your domain.',
131
+ `Set the backend (reverse proxy target) to http://${upstream}:${port} and enable WebSocket support.`,
132
+ 'Turn on the catch-all switch to allow all paths, then save.',
133
+ ],
134
+ }
135
+ }
121
136
  return {
122
137
  ok: true,
123
138
  steps: [