dsh-zen-remote 2.3.10 → 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.
- package/CHANGELOG.md +44 -72
- package/README.md +94 -84
- package/README.zh-CN.md +90 -81
- package/bin/zen-remote.mjs +5 -0
- package/docs/interface.md +6 -6
- package/docs/remote-access.en.md +6 -4
- package/docs/remote-access.md +6 -4
- package/dsh-push.mjs +96 -10
- package/lib/adapters/dsh-better-sidebar/index.js +7 -3
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/admin-routes.js +10 -2
- package/lib/admin-routes.js.map +1 -1
- package/lib/cli/args.js +251 -0
- package/lib/cli/args.js.map +1 -0
- package/lib/cli/bootstrap.js +449 -0
- package/lib/cli/bootstrap.js.map +1 -0
- package/lib/cli/command-setup.js +484 -0
- package/lib/cli/command-setup.js.map +1 -0
- package/lib/cli/commands-client.js +359 -0
- package/lib/cli/commands-client.js.map +1 -0
- package/lib/cli/commands-host.js +1108 -0
- package/lib/cli/commands-host.js.map +1 -0
- package/lib/cli/commands-status.js +279 -0
- package/lib/cli/commands-status.js.map +1 -0
- package/lib/cli/config-writer.js +334 -0
- package/lib/cli/config-writer.js.map +1 -0
- package/lib/cli/context.js +10 -0
- package/lib/cli/context.js.map +1 -0
- package/lib/cli/i18n.js +620 -0
- package/lib/cli/i18n.js.map +1 -0
- package/lib/cli/ipc-client.js +160 -0
- package/lib/cli/ipc-client.js.map +1 -0
- package/lib/cli/ipc-server.js +464 -0
- package/lib/cli/ipc-server.js.map +1 -0
- package/lib/cli/main.js +245 -0
- package/lib/cli/main.js.map +1 -0
- package/lib/cli/qr-terminal.js +48 -0
- package/lib/cli/qr-terminal.js.map +1 -0
- package/lib/client-data/qr.js +286 -0
- package/lib/client-data/qr.js.map +1 -0
- package/lib/client-routes.js +308 -25
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +1013 -229
- package/lib/config.js +50 -12
- package/lib/config.js.map +1 -1
- package/lib/debug-report.js +28 -1
- package/lib/debug-report.js.map +1 -1
- package/lib/fetch-route-intercept.js +334 -26
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +573 -366
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +1833 -280
- package/lib/intercept.js.map +1 -1
- package/lib/lan-gate-server.cjs +36 -10
- package/lib/merge-streams.js +103 -11
- package/lib/merge-streams.js.map +1 -1
- package/lib/plugin-route-intercept.js +93 -63
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/relay-access.js +8 -1
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-client.js +133 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-pool.js +275 -0
- package/lib/relay-pool.js.map +1 -0
- package/lib/relay-server.js +269 -17
- package/lib/relay-server.js.map +1 -1
- package/lib/restart-fields.js +6 -4
- package/lib/restart-fields.js.map +1 -1
- package/lib/share-store.js +65 -27
- package/lib/share-store.js.map +1 -1
- package/lib/state-store.js +400 -0
- package/lib/state-store.js.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/cli/args.d.ts +78 -0
- package/lib/types/cli/args.d.ts.map +1 -0
- package/lib/types/cli/bootstrap.d.ts +170 -0
- package/lib/types/cli/bootstrap.d.ts.map +1 -0
- package/lib/types/cli/command-setup.d.ts +72 -0
- package/lib/types/cli/command-setup.d.ts.map +1 -0
- package/lib/types/cli/commands-client.d.ts +36 -0
- package/lib/types/cli/commands-client.d.ts.map +1 -0
- package/lib/types/cli/commands-host.d.ts +82 -0
- package/lib/types/cli/commands-host.d.ts.map +1 -0
- package/lib/types/cli/commands-status.d.ts +46 -0
- package/lib/types/cli/commands-status.d.ts.map +1 -0
- package/lib/types/cli/config-writer.d.ts +92 -0
- package/lib/types/cli/config-writer.d.ts.map +1 -0
- package/lib/types/cli/context.d.ts +39 -0
- package/lib/types/cli/context.d.ts.map +1 -0
- package/lib/types/cli/i18n.d.ts +32 -0
- package/lib/types/cli/i18n.d.ts.map +1 -0
- package/lib/types/cli/ipc-client.d.ts +83 -0
- package/lib/types/cli/ipc-client.d.ts.map +1 -0
- package/lib/types/cli/ipc-server.d.ts +113 -0
- package/lib/types/cli/ipc-server.d.ts.map +1 -0
- package/lib/types/cli/main.d.ts +57 -0
- package/lib/types/cli/main.d.ts.map +1 -0
- package/lib/types/cli/qr-terminal.d.ts +17 -0
- package/lib/types/cli/qr-terminal.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +4 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -1
- package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +47 -3
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/settings/settings-css.d.ts +1 -1
- package/lib/types/client/settings/settings-css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
- package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
- package/lib/types/client-data/model-group-side.d.ts +23 -2
- package/lib/types/client-data/model-group-side.d.ts.map +1 -1
- package/lib/types/client-data/remote-status.d.ts +47 -1
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +85 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-data/wizard.d.ts +56 -1
- package/lib/types/client-data/wizard.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +25 -1
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +40 -14
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/debug-report.d.ts.map +1 -1
- package/lib/types/fetch-route-intercept.d.ts +66 -8
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +9 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +72 -10
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +79 -1
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +25 -9
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/relay-access.d.ts +3 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-client.d.ts +23 -2
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-pool.d.ts +148 -0
- package/lib/types/relay-pool.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +30 -3
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/restart-fields.d.ts +5 -5
- package/lib/types/restart-fields.d.ts.map +1 -1
- package/lib/types/share-store.d.ts.map +1 -1
- package/lib/types/state-store.d.ts +179 -0
- package/lib/types/state-store.d.ts.map +1 -0
- package/lib/types/upload-limits.d.ts +15 -0
- package/lib/types/upload-limits.d.ts.map +1 -1
- package/lib/types/virtual-id.d.ts +21 -0
- package/lib/types/virtual-id.d.ts.map +1 -1
- package/lib/types/wizard/probe.d.ts.map +1 -1
- package/lib/types/wizard/proxy-snippets.d.ts +3 -0
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
- package/lib/types/wizard/routes.d.ts +1 -0
- package/lib/types/wizard/routes.d.ts.map +1 -1
- package/lib/types/wizard/status.d.ts +9 -0
- package/lib/types/wizard/status.d.ts.map +1 -1
- package/lib/types/wizard/verify.d.ts +3 -0
- package/lib/types/wizard/verify.d.ts.map +1 -1
- package/lib/upload-limits.js +15 -0
- package/lib/upload-limits.js.map +1 -1
- package/lib/virtual-id.js +60 -0
- package/lib/virtual-id.js.map +1 -1
- package/lib/wizard/probe.js +46 -1
- package/lib/wizard/probe.js.map +1 -1
- package/lib/wizard/proxy-snippets.js +14 -1
- package/lib/wizard/proxy-snippets.js.map +1 -1
- package/lib/wizard/routes.js +23 -4
- package/lib/wizard/routes.js.map +1 -1
- package/lib/wizard/status.js +21 -0
- package/lib/wizard/status.js.map +1 -1
- package/lib/wizard/verify.js +31 -8
- package/lib/wizard/verify.js.map +1 -1
- package/package.json +8 -4
- package/pwa/icons/badge-96.png +0 -0
- package/pwa/icons/badge.svg +1 -0
- package/pwa/manifest.json +2 -1
- package/pwa/sw.js +3 -1
- package/scripts/check-debug-report.mjs +41 -1
- package/scripts/check-settings-form.mjs +14 -3
- package/scripts/check-settings-view.mjs +247 -0
- package/scripts/check-wizard-ui.mjs +37 -0
- package/src/adapters/dsh-better-sidebar/index.ts +7 -3
- package/src/admin-routes.ts +10 -2
- package/src/cli/args.ts +289 -0
- package/src/cli/bootstrap.ts +569 -0
- package/src/cli/command-setup.ts +540 -0
- package/src/cli/commands-client.ts +369 -0
- package/src/cli/commands-host.ts +1127 -0
- package/src/cli/commands-status.ts +296 -0
- package/src/cli/config-writer.ts +354 -0
- package/src/cli/context.ts +38 -0
- package/src/cli/i18n.ts +645 -0
- package/src/cli/ipc-client.ts +216 -0
- package/src/cli/ipc-server.ts +531 -0
- package/src/cli/main.ts +285 -0
- package/src/cli/qr-terminal.ts +49 -0
- package/src/client/RemoteStatusIcon.tsx +4 -1
- package/src/client/current-session-report.ts +28 -4
- package/src/client/effects/model-group-side.ts +19 -5
- package/src/client/effects/remote-session.ts +26 -6
- package/src/client/locales.ts +96 -6
- package/src/client/settings/SettingsSection.tsx +287 -92
- package/src/client/settings/settings-css.ts +18 -0
- package/src/client/styles/remote-session.css.ts +15 -1
- package/src/client/wizard/GatewayWizard.tsx +470 -109
- package/src/client/wizard/cards/ProxyCard.tsx +2 -1
- package/src/client/wizard/wizard-css.ts +23 -0
- package/src/client-data/model-group-side.ts +37 -10
- package/src/client-data/remote-status.ts +112 -17
- package/src/client-data/settings-form.ts +164 -8
- package/src/client-data/wizard.ts +112 -2
- package/src/client-routes.ts +334 -26
- package/src/config.ts +62 -16
- package/src/debug-report.ts +27 -1
- package/src/fetch-route-intercept.ts +381 -32
- package/src/index.ts +575 -370
- package/src/intercept.ts +1866 -286
- package/src/merge-streams.ts +155 -10
- package/src/plugin-route-intercept.ts +120 -71
- package/src/relay-access.ts +14 -3
- package/src/relay-client.ts +163 -5
- package/src/relay-pool.ts +387 -0
- package/src/relay-server.ts +312 -16
- package/src/restart-fields.ts +6 -4
- package/src/share-store.ts +59 -26
- package/src/state-store.ts +428 -0
- package/src/upload-limits.ts +16 -0
- package/src/virtual-id.ts +57 -0
- package/src/wizard/probe.ts +54 -2
- package/src/wizard/proxy-snippets.ts +17 -2
- package/src/wizard/routes.ts +24 -5
- package/src/wizard/status.ts +28 -0
- package/src/wizard/verify.ts +33 -11
- package/AGENTS.md +0 -185
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
- package/docs/adr/0002-relay-emulates-websocket.md +0 -8
package/src/client-routes.ts
CHANGED
|
@@ -40,6 +40,8 @@ import type { BridgeStatus } from './plugin-bridge.js'
|
|
|
40
40
|
import { RELAY_HTTP_ROUTES } from './relay-access.js'
|
|
41
41
|
import { relayCredentialsDigest, RelayError } from './relay-client.js'
|
|
42
42
|
import type { RelayClient, RelayHttpResult } from './relay-client.js'
|
|
43
|
+
import { MAX_PAIRED_SERVERS } from './relay-pool.js'
|
|
44
|
+
import type { RelayPool, RelayPoolEntryView } from './relay-pool.js'
|
|
43
45
|
import { resolveDshVersion } from './relay-server.js'
|
|
44
46
|
import { fromVirtual } from './virtual-id.js'
|
|
45
47
|
|
|
@@ -105,6 +107,13 @@ export const CLIENT_UNSHARE_ROUTE = `${CLIENT_ROUTE_PREFIX}/unshare`
|
|
|
105
107
|
* and re-judges the mergers immediately. */
|
|
106
108
|
export const CLIENT_CURRENT_SESSION_ROUTE = `${CLIENT_ROUTE_PREFIX}/current-session`
|
|
107
109
|
|
|
110
|
+
/** POST `{entryId?: string, all?: boolean}` (MS-5): remove one paired server
|
|
111
|
+
* by its entry id, or every paired server at once (`all: true`). The paired
|
|
112
|
+
* list lives in `zen-remote-state.json`; this route goes through the pool so
|
|
113
|
+
* the live clients stop with their records. Pool-mode only — a mount without
|
|
114
|
+
* a pool has no paired list to edit and answers the unknown-path 404. */
|
|
115
|
+
export const CLIENT_UNPAIR_ROUTE = `${CLIENT_ROUTE_PREFIX}/unpair`
|
|
116
|
+
|
|
108
117
|
/** Longest `sessionId` the current-session route accepts (T62): real ids —
|
|
109
118
|
* a local `session-<uuid>` or a `zr~<serverId>~<id>` virtual id — are far
|
|
110
119
|
* shorter; the cap bounds garbage, not reality. */
|
|
@@ -150,6 +159,21 @@ export interface ClientHandlerOptions {
|
|
|
150
159
|
* never connected, so the settings page still gets a fresh answer while
|
|
151
160
|
* the startup `connect()` is still unpaired. */
|
|
152
161
|
getRelayClient?: () => RelayClient | undefined
|
|
162
|
+
/** MS-5 POOL mode: the multi-server relay pool. When present it REPLACES
|
|
163
|
+
* the row-driven single-client facts everywhere: the paired list (not the
|
|
164
|
+
* row's serverUrl/deviceToken) is the truth, claim pairs straight into the
|
|
165
|
+
* pool (the token never reaches the browser), unpair/reconnect address
|
|
166
|
+
* pool entries, and status / remote-status report the whole list (the
|
|
167
|
+
* top level keeps the FIRST entry's fields so the existing page keeps
|
|
168
|
+
* rendering). The single-`getRelayClient` form above stays exactly as it
|
|
169
|
+
* was for the old wiring and the tests. */
|
|
170
|
+
pool?: RelayPool
|
|
171
|
+
/** MS-5: the PB-2 bridge verdict for ONE pool entry, computed PER CALL off
|
|
172
|
+
* that entry's own handshake table and connection state — per-server
|
|
173
|
+
* isolation for the status / remote-status bodies. Undefined (old tests,
|
|
174
|
+
* the unwired host mount): every per-server `pluginBridge` / per-server
|
|
175
|
+
* `bridgedPlugins` list reads `[]`. */
|
|
176
|
+
getPluginBridgeStatusForServer?: (serverId: string | undefined, client: RelayClient) => BridgeStatus[]
|
|
153
177
|
/** The typert interception's diagnostics (T23b-1), read live per request.
|
|
154
178
|
* Shapes, counters and failure codes only — never a token. Undefined when
|
|
155
179
|
* this composition never even attempted an install (no typertGateway). */
|
|
@@ -184,7 +208,9 @@ export interface ClientHandlerOptions {
|
|
|
184
208
|
* mount's remoteStatusOnly wall 404s the route before this matters, and
|
|
185
209
|
* a client handler WITHOUT the sink answers the same 404 (no sink, no
|
|
186
210
|
* route). */
|
|
187
|
-
onCurrentSession?: (read: CurrentSessionRead) => void
|
|
211
|
+
onCurrentSession?: (read: CurrentSessionRead, reportedLang?: 'zh' | 'en') => void
|
|
212
|
+
/** Language for route error messages (`'zh'` default, `'en'` English). */
|
|
213
|
+
lang?: 'zh' | 'en' | (() => 'zh' | 'en') | undefined
|
|
188
214
|
/** CU-3, the debug-report route's inputs — all optional; the route answers
|
|
189
215
|
* its 404-shaped refusal without them (an unwired composition must not
|
|
190
216
|
* pretend to have diagnostics). Wired on BOTH role mounts.
|
|
@@ -424,28 +450,24 @@ function remoteStatusStateOf(relay: RelayClient | undefined): 'online' | 'offlin
|
|
|
424
450
|
}
|
|
425
451
|
|
|
426
452
|
/**
|
|
427
|
-
* The
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
453
|
+
* The diagnostics decorator shared by the single-client and pool status
|
|
454
|
+
* bodies (CU-3's extraction, extended by MS-5): every interception getter is
|
|
455
|
+
* read live per call, and the compat verdict rides whenever there is
|
|
456
|
+
* something to report — from `compatRelay`'s last handshake (the single
|
|
457
|
+
* client, or in pool mode the FIRST entry's client) or from the interceptor's
|
|
458
|
+
* incompatible-call ring. `pluginBridgeOverride` replaces the single-client
|
|
459
|
+
* verdict source in pool mode (the first entry's per-server verdicts, keeping
|
|
460
|
+
* the top-level field's shape).
|
|
433
461
|
*/
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
// compat verdict (T42) appears once there is something to report: a
|
|
440
|
-
// stored comparison from the relay client's last handshake, or any
|
|
441
|
-
// incompatible call the interceptor recorded. Absent otherwise —
|
|
442
|
-
// nothing to show, and the settings block presence-gates on exactly
|
|
443
|
-
// that.
|
|
462
|
+
function diagnosticsDecorator(
|
|
463
|
+
options: ClientHandlerOptions,
|
|
464
|
+
compatRelay: RelayClient | undefined,
|
|
465
|
+
pluginBridgeOverride?: BridgeStatus[],
|
|
466
|
+
): (body: Record<string, unknown>) => Record<string, unknown> {
|
|
444
467
|
const intercept = options.getIntercept?.()
|
|
445
468
|
const fetchRouteIntercept = options.getFetchRouteIntercept?.()
|
|
446
469
|
const serviceProbe = options.getServiceProbe?.()
|
|
447
470
|
const pluginRouteIntercept = options.getPluginRouteIntercept?.()
|
|
448
|
-
const compatRelay = options.getRelayClient?.()
|
|
449
471
|
const compat =
|
|
450
472
|
compatRelay?.compat !== undefined || (intercept?.incompatibleCalls.length ?? 0) > 0
|
|
451
473
|
? {
|
|
@@ -455,17 +477,97 @@ async function statusBodyOf(options: ClientHandlerOptions): Promise<Record<strin
|
|
|
455
477
|
incompatibleCalls: intercept?.incompatibleCalls ?? [],
|
|
456
478
|
}
|
|
457
479
|
: undefined
|
|
458
|
-
|
|
480
|
+
return (body: Record<string, unknown>): Record<string, unknown> => {
|
|
459
481
|
// PB-2: the per-plugin bridge verdict rides EVERY status answer —
|
|
460
482
|
// the settings surface explains a not-bridged plugin whatever the
|
|
461
483
|
// connection state (unpaired reads all-offline, the honest verdict).
|
|
462
|
-
let next: Record<string, unknown> = { ...body, pluginBridge: options.getPluginBridgeStatus?.() ?? [] }
|
|
484
|
+
let next: Record<string, unknown> = { ...body, pluginBridge: pluginBridgeOverride ?? options.getPluginBridgeStatus?.() ?? [] }
|
|
463
485
|
if (intercept !== undefined) next = { ...next, intercept }
|
|
464
486
|
if (fetchRouteIntercept !== undefined) next = { ...next, fetchRouteIntercept }
|
|
465
487
|
if (serviceProbe !== undefined) next = { ...next, serviceProbe }
|
|
466
488
|
if (pluginRouteIntercept !== undefined) next = { ...next, pluginRouteIntercept }
|
|
467
489
|
return compat === undefined ? next : { ...next, compat }
|
|
468
490
|
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* One pool entry's per-server status row (MS-5): the live client facts and
|
|
495
|
+
* the entry's own identity — NEVER the token, which the route never even
|
|
496
|
+
* sees (it lives in the state file, behind the pool). `compat` appears only
|
|
497
|
+
* once that client has a handshake verdict to report; `pluginBridge` comes
|
|
498
|
+
* from the per-server verdict getter ([] unwired).
|
|
499
|
+
*/
|
|
500
|
+
function poolServerStatus(entry: RelayPoolEntryView, options: ClientHandlerOptions): Record<string, unknown> {
|
|
501
|
+
const client = entry.client
|
|
502
|
+
const body: Record<string, unknown> = {
|
|
503
|
+
entryId: entry.entryId,
|
|
504
|
+
serverId: entry.serverId,
|
|
505
|
+
serverUrl: entry.serverUrl,
|
|
506
|
+
serverName: entry.serverName ?? '',
|
|
507
|
+
displayName: entry.displayName,
|
|
508
|
+
state: client.state,
|
|
509
|
+
nextRetryAt: client.nextRetryAt ?? null,
|
|
510
|
+
...(client.lastError !== undefined ? { lastError: client.lastError } : {}),
|
|
511
|
+
...(client.compat !== undefined
|
|
512
|
+
? { compat: { identical: client.compat.identical, different: client.compat.different, unavailable: client.compat.unavailable } }
|
|
513
|
+
: {}),
|
|
514
|
+
pluginBridge: options.getPluginBridgeStatusForServer?.(entry.serverId, client) ?? [],
|
|
515
|
+
}
|
|
516
|
+
return body
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The POOL status body (MS-5): the paired list is the truth — the row's
|
|
521
|
+
* serverUrl/deviceToken are never consulted. The top level keeps the FIRST
|
|
522
|
+
* entry's compatibility fields (the old single-server page keeps rendering)
|
|
523
|
+
* and every entry reports as one `servers` row; `maxServers` names the cap.
|
|
524
|
+
* An empty pool answers `unpaired` — the honest "nothing paired".
|
|
525
|
+
*/
|
|
526
|
+
async function poolStatusBodyOf(options: ClientHandlerOptions): Promise<Record<string, unknown>> {
|
|
527
|
+
const pool = options.pool as RelayPool
|
|
528
|
+
const entries = pool.entries()
|
|
529
|
+
const first = entries[0]
|
|
530
|
+
const bridgeOverride = first !== undefined
|
|
531
|
+
? options.getPluginBridgeStatusForServer?.(first.serverId, first.client) ?? []
|
|
532
|
+
: []
|
|
533
|
+
const withDiagnostics = diagnosticsDecorator(options, first?.client, bridgeOverride)
|
|
534
|
+
const servers = entries.map((entry) => poolServerStatus(entry, options))
|
|
535
|
+
if (first === undefined) {
|
|
536
|
+
return withDiagnostics({ state: 'unpaired', maxServers: MAX_PAIRED_SERVERS, servers })
|
|
537
|
+
}
|
|
538
|
+
const body = deviceStatusBody(
|
|
539
|
+
first.client.state,
|
|
540
|
+
{ serverName: first.serverName ?? '', deviceName: first.client.handshakeInfo?.deviceName },
|
|
541
|
+
first.serverUrl,
|
|
542
|
+
first.client.deviceNameSyncing,
|
|
543
|
+
)
|
|
544
|
+
if (first.client.nextRetryAt !== null && first.client.nextRetryAt !== undefined) body.nextRetryAt = first.client.nextRetryAt
|
|
545
|
+
if (first.client.lastError !== undefined) body.lastError = first.client.lastError
|
|
546
|
+
return withDiagnostics({ ...body, maxServers: MAX_PAIRED_SERVERS, servers })
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The status route's body, computed exactly as the CLIENT_STATUS_ROUTE
|
|
551
|
+
* branch always has — extracted (CU-3) so the debug-report route can embed
|
|
552
|
+
* the same diagnostics aggregation without a second implementation. All
|
|
553
|
+
* reads are live per call: volatile row fields, the interception getters,
|
|
554
|
+
* the relay client's current state. May await one bounded probe or connect
|
|
555
|
+
* (never longer than the probe timeout). In pool mode the paired list is
|
|
556
|
+
* the truth instead ({@link poolStatusBodyOf}); the row is not consulted.
|
|
557
|
+
*/
|
|
558
|
+
async function statusBodyOf(options: ClientHandlerOptions): Promise<Record<string, unknown>> {
|
|
559
|
+
const fetchImpl: FetchLike = options.fetchImpl ?? ((url, init) => fetch(url, init))
|
|
560
|
+
if (options.pool !== undefined) return poolStatusBodyOf(options)
|
|
561
|
+
// The typert interception's view (T23b-1) rides on EVERY status
|
|
562
|
+
// answer, whatever the connection state — the settings surface needs
|
|
563
|
+
// it to explain a refused install even while unpaired. The interface
|
|
564
|
+
// compat verdict (T42) appears once there is something to report: a
|
|
565
|
+
// stored comparison from the relay client's last handshake, or any
|
|
566
|
+
// incompatible call the interceptor recorded. Absent otherwise —
|
|
567
|
+
// nothing to show, and the settings block presence-gates on exactly
|
|
568
|
+
// that.
|
|
569
|
+
const compatRelay = options.getRelayClient?.()
|
|
570
|
+
const withDiagnostics = diagnosticsDecorator(options, compatRelay)
|
|
469
571
|
// The ROW decides its own two shapes first, per request (both fields
|
|
470
572
|
// are volatile { get() } wrappers — only the row object apply()
|
|
471
573
|
// received is a live source): without both credentials nothing is
|
|
@@ -527,6 +629,12 @@ async function statusBodyOf(options: ClientHandlerOptions): Promise<Record<strin
|
|
|
527
629
|
*/
|
|
528
630
|
export function createClientHandler(options: ClientHandlerOptions): ClientHandler {
|
|
529
631
|
const fetchImpl: FetchLike = options.fetchImpl ?? ((url, init) => fetch(url, init))
|
|
632
|
+
const resolveLang = (): 'zh' | 'en' => {
|
|
633
|
+
const value = typeof options.lang === 'function' ? options.lang() : options.lang
|
|
634
|
+
return value === 'en' ? 'en' : 'zh'
|
|
635
|
+
}
|
|
636
|
+
const mismatchMessage = (): string =>
|
|
637
|
+
resolveLang() === 'en' ? 'This remote session belongs to another server' : '此远程会话属于其他主服务端'
|
|
530
638
|
return async (req, res) => {
|
|
531
639
|
// Wall 1: DSH's own admission, relaying its refusal verbatim.
|
|
532
640
|
const admission = options.admit(req)
|
|
@@ -549,7 +657,10 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
549
657
|
if (route === CLIENT_DEBUG_REPORT_ROUTE && req.headers['x-zen-remote-via'] !== undefined) {
|
|
550
658
|
responseJson(res, 403, {
|
|
551
659
|
ok: false,
|
|
552
|
-
error: {
|
|
660
|
+
error: {
|
|
661
|
+
code: 'via-gateway',
|
|
662
|
+
message: resolveLang() === 'en' ? 'Diagnostics report can only be exported on this machine' : '诊断报告只能在本机导出',
|
|
663
|
+
},
|
|
553
664
|
})
|
|
554
665
|
return
|
|
555
666
|
}
|
|
@@ -580,6 +691,23 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
580
691
|
responseJson(res, 400, { ok: false, code: normalized.reason })
|
|
581
692
|
return
|
|
582
693
|
}
|
|
694
|
+
// MS-5 pool mode: the 8-server cap is judged BEFORE the pairing
|
|
695
|
+
// round-trip — a refusal the user can fix locally (remove a server,
|
|
696
|
+
// or re-enter an already-paired address) must not spend a claim code
|
|
697
|
+
// on the wire. An address that is ALREADY paired is always allowed
|
|
698
|
+
// through: re-pairing it just refreshes the token in place, at 8/8
|
|
699
|
+
// too. The pool stays the single writer afterwards: the gateway's
|
|
700
|
+
// token goes straight into `zen-remote-state.json` and NEVER reaches
|
|
701
|
+
// the browser — this response carries only the entry id and the
|
|
702
|
+
// address echo.
|
|
703
|
+
const pool = options.pool
|
|
704
|
+
if (pool !== undefined) {
|
|
705
|
+
const known = pool.entries().some((entry) => entry.serverUrl === normalized.url)
|
|
706
|
+
if (!known && pool.entries().length >= MAX_PAIRED_SERVERS) {
|
|
707
|
+
responseJson(res, 409, { ok: false, code: 'limit-reached' })
|
|
708
|
+
return
|
|
709
|
+
}
|
|
710
|
+
}
|
|
583
711
|
const payload = JSON.stringify({
|
|
584
712
|
code: stringOrEmpty(body.code),
|
|
585
713
|
name: stringOrEmpty(body.name),
|
|
@@ -614,7 +742,68 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
614
742
|
responseJson(res, 502, { ok: false, code: 'unreachable' })
|
|
615
743
|
return
|
|
616
744
|
}
|
|
617
|
-
|
|
745
|
+
const outcome = classifyClaimResponse(status, gatewayBody)
|
|
746
|
+
if (pool === undefined || !outcome.ok) {
|
|
747
|
+
respondClaim(res, outcome, normalized.url)
|
|
748
|
+
return
|
|
749
|
+
}
|
|
750
|
+
// Pool mode, redemption succeeded: land the pairing in the state
|
|
751
|
+
// file through the pool (which also starts/refreshes the live
|
|
752
|
+
// client) and answer without the token. The pair() can only refuse
|
|
753
|
+
// past the pre-check on a concurrent writer — the same envelopes.
|
|
754
|
+
const paired = await pool.pair({
|
|
755
|
+
serverUrl: normalized.url,
|
|
756
|
+
token: outcome.token,
|
|
757
|
+
deviceId: outcome.deviceId,
|
|
758
|
+
deviceName: outcome.deviceName,
|
|
759
|
+
})
|
|
760
|
+
if (!paired.ok) {
|
|
761
|
+
responseJson(res, paired.code === 'limit-reached' ? 409 : 400, { ok: false, code: paired.code })
|
|
762
|
+
return
|
|
763
|
+
}
|
|
764
|
+
responseJson(res, 200, {
|
|
765
|
+
ok: true,
|
|
766
|
+
entryId: paired.entry.entryId,
|
|
767
|
+
deviceId: outcome.deviceId,
|
|
768
|
+
deviceName: outcome.deviceName,
|
|
769
|
+
serverUrl: normalized.url,
|
|
770
|
+
})
|
|
771
|
+
return
|
|
772
|
+
}
|
|
773
|
+
if (route === CLIENT_UNPAIR_ROUTE) {
|
|
774
|
+
// MS-5: remove one paired server or all of them. Pool-mode only —
|
|
775
|
+
// a mount without a pool has no paired list to edit (the old
|
|
776
|
+
// single-server pairing lived in the ROW, which the browser writes
|
|
777
|
+
// directly), and answers the same 404 an unknown path would.
|
|
778
|
+
const pool = options.pool
|
|
779
|
+
if (pool === undefined) {
|
|
780
|
+
responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown client route' } })
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
if (method !== 'POST') {
|
|
784
|
+
res.setHeader('Allow', 'POST')
|
|
785
|
+
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use POST' } })
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
if (!sameOriginPost(req)) {
|
|
789
|
+
responseJson(res, 403, {
|
|
790
|
+
ok: false,
|
|
791
|
+
error: { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' },
|
|
792
|
+
})
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
const body = await readJsonBody(req)
|
|
796
|
+
if (body.all === true) {
|
|
797
|
+
const removed = pool.unpairAll()
|
|
798
|
+
responseJson(res, 200, { ok: true, removed })
|
|
799
|
+
return
|
|
800
|
+
}
|
|
801
|
+
if (typeof body.entryId === 'string' && body.entryId !== '') {
|
|
802
|
+
const removed = pool.unpair(body.entryId)
|
|
803
|
+
responseJson(res, 200, { ok: true, removed: removed ? 1 : 0 })
|
|
804
|
+
return
|
|
805
|
+
}
|
|
806
|
+
responseJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'entryId (non-empty string) or all (true) is required' } })
|
|
618
807
|
return
|
|
619
808
|
}
|
|
620
809
|
if (route === CLIENT_STATUS_ROUTE) {
|
|
@@ -672,6 +861,8 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
672
861
|
pushSummary: values.pushSummary,
|
|
673
862
|
pushTurnEnd: values.pushTurnEnd,
|
|
674
863
|
pushTool: values.pushTool,
|
|
864
|
+
pushEnabled: values.pushEnabled,
|
|
865
|
+
pushSessionName: values.pushSessionName,
|
|
675
866
|
serverUrl: stringOrEmpty(unwrapVolatile(record.serverUrl)).trim(),
|
|
676
867
|
},
|
|
677
868
|
statusBody: await statusBodyOf(options),
|
|
@@ -696,6 +887,31 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
696
887
|
return
|
|
697
888
|
}
|
|
698
889
|
const relay = options.getRelayClient?.()
|
|
890
|
+
// MS-5 pool mode: an optional { entryId } names ONE server — the
|
|
891
|
+
// same offline-only, 409-on-conflict contract the single client's
|
|
892
|
+
// button has, per entry; without it every OFFLINE server gets one
|
|
893
|
+
// attempt (an online server is not "reconnected", it is fine).
|
|
894
|
+
const pool = options.pool
|
|
895
|
+
if (pool !== undefined) {
|
|
896
|
+
const body = await readJsonBody(req)
|
|
897
|
+
const entryId = typeof body.entryId === 'string' && body.entryId !== '' ? body.entryId : undefined
|
|
898
|
+
if (entryId === undefined) {
|
|
899
|
+
const started = await pool.reconnect()
|
|
900
|
+
responseJson(res, 200, { ok: true, started })
|
|
901
|
+
return
|
|
902
|
+
}
|
|
903
|
+
const entry = pool.getByEntryId(entryId)
|
|
904
|
+
if (entry === undefined) {
|
|
905
|
+
responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'No such paired server' } })
|
|
906
|
+
return
|
|
907
|
+
}
|
|
908
|
+
if (entry.client.state !== 'offline' || !entry.client.reconnect()) {
|
|
909
|
+
responseJson(res, 409, { ok: false, error: { code: 'not-offline', message: 'The client is not offline' } })
|
|
910
|
+
return
|
|
911
|
+
}
|
|
912
|
+
responseJson(res, 200, { ok: true })
|
|
913
|
+
return
|
|
914
|
+
}
|
|
699
915
|
// No relay client (a composition that never built one): there is
|
|
700
916
|
// nothing to reconnect, and the probe-shaped status route is the
|
|
701
917
|
// only connection surface this deployment has.
|
|
@@ -729,6 +945,16 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
729
945
|
return
|
|
730
946
|
}
|
|
731
947
|
const relay = options.getRelayClient?.()
|
|
948
|
+
// MS-5 pool mode: the same QUESTION per server — one authenticated
|
|
949
|
+
// ping sweep over the offline clients, the reachable ones get the
|
|
950
|
+
// controlled `networkRestored()` (probe-first, no storm, offline
|
|
951
|
+
// only — the pool method enforces the same rules per client).
|
|
952
|
+
const pool = options.pool
|
|
953
|
+
if (pool !== undefined) {
|
|
954
|
+
const outcome = await pool.probeAndRestoreAll()
|
|
955
|
+
responseJson(res, 200, { ok: true, reachable: outcome.reachable, reconnected: outcome.reconnected })
|
|
956
|
+
return
|
|
957
|
+
}
|
|
732
958
|
if (relay === undefined) {
|
|
733
959
|
responseJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'No relay client is running' } })
|
|
734
960
|
return
|
|
@@ -762,8 +988,41 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
762
988
|
// sort is pinned here so the field's shape never depends on the
|
|
763
989
|
// registry's internal order). The host mount — no verdict source —
|
|
764
990
|
// answers [].
|
|
765
|
-
|
|
991
|
+
// MS-5 pool mode: the SAME shape per server, under `servers` keyed
|
|
992
|
+
// by the handshake serverId (an entry that never handshook owns no
|
|
993
|
+
// virtual ids and is not addressable — skipped). The top level
|
|
994
|
+
// mirrors the FIRST entry so the old page keeps rendering.
|
|
995
|
+
const pool = options.pool
|
|
766
996
|
const intercept = options.getIntercept?.()
|
|
997
|
+
const bridgedOf = (serverId: string | undefined, client: RelayClient): string[] =>
|
|
998
|
+
(options.getPluginBridgeStatusForServer?.(serverId, client) ?? [])
|
|
999
|
+
.filter((status) => status.bridged)
|
|
1000
|
+
.map((status) => status.plugin)
|
|
1001
|
+
.sort()
|
|
1002
|
+
if (pool !== undefined) {
|
|
1003
|
+
const entries = pool.entries()
|
|
1004
|
+
const first = entries[0]
|
|
1005
|
+
const servers: Record<string, Record<string, unknown>> = {}
|
|
1006
|
+
for (const entry of entries) {
|
|
1007
|
+
if (entry.serverId === undefined) continue
|
|
1008
|
+
servers[entry.serverId] = {
|
|
1009
|
+
state: remoteStatusStateOf(entry.client),
|
|
1010
|
+
versionMismatch: (entry.client.compat?.different?.length ?? 0) > 0,
|
|
1011
|
+
serverName: entry.serverName ?? '',
|
|
1012
|
+
bridgedPlugins: bridgedOf(entry.serverId, entry.client),
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
responseJson(res, 200, {
|
|
1016
|
+
state: remoteStatusStateOf(first?.client),
|
|
1017
|
+
versionMismatch: (first?.client.compat?.different?.length ?? 0) > 0,
|
|
1018
|
+
serverName: first?.serverName ?? '',
|
|
1019
|
+
closed: Object.fromEntries((intercept?.closedSessions ?? []).map((record) => [record.sessionId, record.reason])),
|
|
1020
|
+
bridgedPlugins: first !== undefined ? bridgedOf(first.serverId, first.client) : [],
|
|
1021
|
+
servers,
|
|
1022
|
+
})
|
|
1023
|
+
return
|
|
1024
|
+
}
|
|
1025
|
+
const relay = options.getRelayClient?.()
|
|
767
1026
|
responseJson(res, 200, {
|
|
768
1027
|
state: remoteStatusStateOf(relay),
|
|
769
1028
|
versionMismatch: (relay?.compat?.different?.length ?? 0) > 0,
|
|
@@ -807,7 +1066,8 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
807
1066
|
return
|
|
808
1067
|
}
|
|
809
1068
|
const read: CurrentSessionRead = raw === null ? { kind: 'none' } : { kind: 'open', sessionId: raw }
|
|
810
|
-
|
|
1069
|
+
const reportedLang = body.lang === 'en' ? ('en' as const) : body.lang === 'zh' ? ('zh' as const) : undefined
|
|
1070
|
+
sink(read, reportedLang)
|
|
811
1071
|
responseJson(res, 200, { ok: true })
|
|
812
1072
|
return
|
|
813
1073
|
}
|
|
@@ -832,6 +1092,25 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
832
1092
|
responseJson(res, 400, { ok: false, error: { code: 'not-virtual', message: 'sessionId must be a remote (virtual) session id' } })
|
|
833
1093
|
return
|
|
834
1094
|
}
|
|
1095
|
+
// MS-5 pool mode: the id's serverId half names the server — its own
|
|
1096
|
+
// client closes the session with the ORIGINAL id. A server the pool
|
|
1097
|
+
// does not hold is the same remote-mismatch the interceptor answers.
|
|
1098
|
+
const pool = options.pool
|
|
1099
|
+
if (pool !== undefined) {
|
|
1100
|
+
const entry = pool.getByServerId(parts.serverId)
|
|
1101
|
+
if (entry === undefined) {
|
|
1102
|
+
responseJson(res, 200, { ok: false, error: { code: 'remote-mismatch', message: mismatchMessage() } })
|
|
1103
|
+
return
|
|
1104
|
+
}
|
|
1105
|
+
try {
|
|
1106
|
+
await entry.client.unshare(parts.id)
|
|
1107
|
+
responseJson(res, 200, { ok: true })
|
|
1108
|
+
} catch (error) {
|
|
1109
|
+
const code = error instanceof RelayError ? error.code : 'internal'
|
|
1110
|
+
responseJson(res, 200, { ok: false, error: { code, message: error instanceof Error ? error.message : String(error) } })
|
|
1111
|
+
}
|
|
1112
|
+
return
|
|
1113
|
+
}
|
|
835
1114
|
const relay = options.getRelayClient?.()
|
|
836
1115
|
if (relay === undefined) {
|
|
837
1116
|
responseJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'No relay client is running' } })
|
|
@@ -841,7 +1120,7 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
841
1120
|
// just answer not-shared out there — the mismatch is answerable
|
|
842
1121
|
// locally, with the code the interceptor uses for the same fact.
|
|
843
1122
|
if (relay.handshakeInfo === undefined || relay.handshakeInfo.serverId !== parts.serverId) {
|
|
844
|
-
responseJson(res, 200, { ok: false, error: { code: 'remote-mismatch', message:
|
|
1123
|
+
responseJson(res, 200, { ok: false, error: { code: 'remote-mismatch', message: mismatchMessage() } })
|
|
845
1124
|
return
|
|
846
1125
|
}
|
|
847
1126
|
try {
|
|
@@ -888,13 +1167,42 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
888
1167
|
responseJson(res, 400, { ok: false, error: { code: 'not-virtual', message: 'sessionId is not a remote session id' } })
|
|
889
1168
|
return
|
|
890
1169
|
}
|
|
1170
|
+
// MS-5 pool mode: route by the id's serverId half. A server the pool
|
|
1171
|
+
// does not hold refuses like a foreign one; its client must be the
|
|
1172
|
+
// ONLINE one before the query travels with the original id restored.
|
|
1173
|
+
const pool = options.pool
|
|
1174
|
+
if (pool !== undefined) {
|
|
1175
|
+
const entry = pool.getByServerId(parts.serverId)
|
|
1176
|
+
if (entry === undefined) {
|
|
1177
|
+
responseJson(res, 400, { ok: false, error: { code: 'remote-mismatch', message: mismatchMessage() } })
|
|
1178
|
+
return
|
|
1179
|
+
}
|
|
1180
|
+
if (entry.client.state !== 'online') {
|
|
1181
|
+
responseJson(res, 503, { ok: false, error: { code: 'remote-offline' } })
|
|
1182
|
+
return
|
|
1183
|
+
}
|
|
1184
|
+
url.searchParams.set('sessionId', parts.id)
|
|
1185
|
+
// A browser walk-off cancels the round-trip mid-flight; the write
|
|
1186
|
+
// guard in the catch keeps a settled answer from racing the close.
|
|
1187
|
+
const hangUp = new AbortController()
|
|
1188
|
+
res.once('close', () => {
|
|
1189
|
+
if (!res.writableEnded) hangUp.abort()
|
|
1190
|
+
})
|
|
1191
|
+
try {
|
|
1192
|
+
const result = await entry.client.http(httpRoute, url.searchParams.toString(), hangUp.signal)
|
|
1193
|
+
respondUpstream(res, result)
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
if (!res.writableEnded && !res.destroyed) respondRelayFailure(res, error)
|
|
1196
|
+
}
|
|
1197
|
+
return
|
|
1198
|
+
}
|
|
891
1199
|
const relay = options.getRelayClient?.()
|
|
892
1200
|
if (relay === undefined || relay.state !== 'online') {
|
|
893
1201
|
responseJson(res, 503, { ok: false, error: { code: 'remote-offline' } })
|
|
894
1202
|
return
|
|
895
1203
|
}
|
|
896
1204
|
if (relay.handshakeInfo?.serverId !== parts.serverId) {
|
|
897
|
-
responseJson(res, 400, { ok: false, error: { code: 'remote-mismatch', message:
|
|
1205
|
+
responseJson(res, 400, { ok: false, error: { code: 'remote-mismatch', message: mismatchMessage() } })
|
|
898
1206
|
return
|
|
899
1207
|
}
|
|
900
1208
|
url.searchParams.set('sessionId', parts.id)
|
package/src/config.ts
CHANGED
|
@@ -41,9 +41,11 @@ const SERVER_NAME_MAX = 40
|
|
|
41
41
|
|
|
42
42
|
/** The effective configuration one resolved value set, defaults included. */
|
|
43
43
|
export interface ZenRemoteConfig {
|
|
44
|
-
/** Which half of the plugin runs in this DSH process
|
|
45
|
-
*
|
|
46
|
-
|
|
44
|
+
/** Which half of the plugin runs in this DSH process. `'host'` serves the
|
|
45
|
+
* gateway and never dials out; `'client'` dials one server and serves no
|
|
46
|
+
* gateway; `'peer'` (MS Phase 2) does both. Anything the layers leave
|
|
47
|
+
* unresolved means `'host'` (see {@link resolveRole}). */
|
|
48
|
+
role: 'host' | 'client' | 'peer'
|
|
47
49
|
/** Port the gateway child listens on. */
|
|
48
50
|
port: number
|
|
49
51
|
/** Bind address of the gateway child. */
|
|
@@ -69,6 +71,12 @@ export interface ZenRemoteConfig {
|
|
|
69
71
|
pushTurnEnd: boolean
|
|
70
72
|
/** Register the model-facing `push_notify` tool. */
|
|
71
73
|
pushTool: boolean
|
|
74
|
+
/** Master switch for session notifications: when false, disables all push
|
|
75
|
+
* listeners and does not register push_notify. */
|
|
76
|
+
pushEnabled: boolean
|
|
77
|
+
/** Include the source session name on the first line of push notification
|
|
78
|
+
* bodies. */
|
|
79
|
+
pushSessionName: boolean
|
|
72
80
|
/** Server display name, at most {@link SERVER_NAME_MAX} characters. */
|
|
73
81
|
serverName: string
|
|
74
82
|
/** Idle-sleep window of a remote-enabled session: after this many hours
|
|
@@ -120,6 +128,8 @@ export const DEFAULTS: ZenRemoteConfig = {
|
|
|
120
128
|
pushSummary: false,
|
|
121
129
|
pushTurnEnd: false,
|
|
122
130
|
pushTool: true,
|
|
131
|
+
pushEnabled: true,
|
|
132
|
+
pushSessionName: false,
|
|
123
133
|
serverName: defaultServerName(),
|
|
124
134
|
idleHours: 48,
|
|
125
135
|
autoShareNewSessions: false,
|
|
@@ -335,7 +345,7 @@ interface FieldResolver<T> {
|
|
|
335
345
|
}
|
|
336
346
|
|
|
337
347
|
const FIELDS: { [K in keyof ZenRemoteConfig]: FieldResolver<ZenRemoteConfig[K]> } = {
|
|
338
|
-
role: { row: oneOf('host', 'client'), file: oneOf('host', 'client') },
|
|
348
|
+
role: { row: oneOf('host', 'client', 'peer'), file: oneOf('host', 'client', 'peer') },
|
|
339
349
|
port: { env: envNumber('LAN_GATE_PORT', intIn(1, 65535)), row: intIn(1, 65535), file: lenientFileNumber(intIn(1, 65535)) },
|
|
340
350
|
host: { env: envString('LAN_GATE_HOST'), row: nonEmptyString, file: nonEmptyString },
|
|
341
351
|
targetPort: { env: envNumber('LAN_GATE_TARGET_PORT', intIn(1, 65535)), row: intIn(1, 65535) },
|
|
@@ -348,6 +358,12 @@ const FIELDS: { [K in keyof ZenRemoteConfig]: FieldResolver<ZenRemoteConfig[K]>
|
|
|
348
358
|
pushSummary: { env: envBool('DSH_PUSH_SUMMARY', (raw) => raw === '1'), row: boolLike, file: boolLike },
|
|
349
359
|
pushTurnEnd: { env: envBool('DSH_PUSH_TURN_END', (raw) => raw === '1'), row: boolLike, file: boolLike },
|
|
350
360
|
pushTool: { env: envBool('DSH_PUSH_TOOL', (raw) => raw !== '0'), row: boolLike, file: boolLike },
|
|
361
|
+
// The master switch copies pushTool's env shape (default on, only the exact
|
|
362
|
+
// '0' flips it off); the session-name line copies pushSummary's (default
|
|
363
|
+
// off, '1' turns it on). Both restart the push leg because apply() wires
|
|
364
|
+
// its listeners from them (P55-1) — hence the RESTART_FIELDS entries.
|
|
365
|
+
pushEnabled: { env: envBool('DSH_PUSH_ENABLED', (raw) => raw !== '0'), row: boolLike, file: boolLike },
|
|
366
|
+
pushSessionName: { env: envBool('DSH_PUSH_SESSION_NAME', (raw) => raw === '1'), row: boolLike, file: boolLike },
|
|
351
367
|
serverName: { row: serverNameOf, file: serverNameOf },
|
|
352
368
|
idleHours: { row: inRange(0, 8760), file: inRange(0, 8760) },
|
|
353
369
|
autoShareNewSessions: { row: boolLike, file: boolLike },
|
|
@@ -444,6 +460,8 @@ export function resolveConfig(
|
|
|
444
460
|
pushSummary: resolveField('pushSummary', safeRow, safeFile, env, sources),
|
|
445
461
|
pushTurnEnd: resolveField('pushTurnEnd', safeRow, safeFile, env, sources),
|
|
446
462
|
pushTool: resolveField('pushTool', safeRow, safeFile, env, sources),
|
|
463
|
+
pushEnabled: resolveField('pushEnabled', safeRow, safeFile, env, sources),
|
|
464
|
+
pushSessionName: resolveField('pushSessionName', safeRow, safeFile, env, sources),
|
|
447
465
|
serverName: resolveField('serverName', safeRow, safeFile, env, sources),
|
|
448
466
|
idleHours: resolveField('idleHours', safeRow, safeFile, env, sources),
|
|
449
467
|
autoShareNewSessions: resolveField('autoShareNewSessions', safeRow, safeFile, env, sources),
|
|
@@ -475,19 +493,42 @@ export function readFileConfig(env: NodeJS.ProcessEnv = process.env): Record<str
|
|
|
475
493
|
}
|
|
476
494
|
|
|
477
495
|
/**
|
|
478
|
-
* The normalized role behind a row's `role` knob.
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
* a `{ get() }` live reference,
|
|
483
|
-
*
|
|
484
|
-
* {@link resolveConfig} validates each
|
|
485
|
-
* first, so an invalid row role can still be rescued
|
|
486
|
-
* function answers the final merged value. Kept on
|
|
487
|
-
* surface for test/role-wiring.test.cjs.
|
|
496
|
+
* The normalized role behind a row's `role` knob. Only the exact strings
|
|
497
|
+
* `'client'` and `'peer'` mean those roles — everything else degrades to
|
|
498
|
+
* host, so a typo runs the full plugin rather than silently dropping the
|
|
499
|
+
* gateway and push halves. The value is read through {@link unwrapVolatile}
|
|
500
|
+
* first: since T17 every row field arrives as a `{ get() }` live reference,
|
|
501
|
+
* and comparing the wrapper itself against a role string would always answer
|
|
502
|
+
* host (T17b). Resolution-order note: {@link resolveConfig} validates each
|
|
503
|
+
* layer against the enum first, so an invalid row role can still be rescued
|
|
504
|
+
* by the file layer; this function answers the final merged value. Kept on
|
|
505
|
+
* the main entry's export surface for test/role-wiring.test.cjs.
|
|
488
506
|
*/
|
|
489
|
-
export function resolveRole(row: unknown): 'host' | 'client' {
|
|
490
|
-
|
|
507
|
+
export function resolveRole(row: unknown): 'host' | 'client' | 'peer' {
|
|
508
|
+
const value = unwrapVolatile((row as { role?: unknown } | null | undefined)?.role)
|
|
509
|
+
if (value === 'client') return 'client'
|
|
510
|
+
if (value === 'peer') return 'peer'
|
|
511
|
+
return 'host'
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Whether the role serves remote access to OTHER machines: host and peer do
|
|
516
|
+
* (the gateway, relay and push halves mount), a pure client does not. The
|
|
517
|
+
* role-gate the wiring reads INSTEAD of a `=== 'host'` compare, so a peer
|
|
518
|
+
* deploy gets the serving halves without every call site growing a third
|
|
519
|
+
* branch (MS Phase 2 wires it through).
|
|
520
|
+
*/
|
|
521
|
+
export function servesRole(role: 'host' | 'client' | 'peer'): boolean {
|
|
522
|
+
return role !== 'client'
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Whether the role dials OUT to paired servers: client and peer do (the
|
|
527
|
+
* relay client / pool), a pure host has nothing to dial. {@link servesRole}'s
|
|
528
|
+
* counterpart.
|
|
529
|
+
*/
|
|
530
|
+
export function connectsRole(role: 'host' | 'client' | 'peer'): boolean {
|
|
531
|
+
return role !== 'host'
|
|
491
532
|
}
|
|
492
533
|
|
|
493
534
|
/**
|
|
@@ -536,6 +577,11 @@ export const Config = z.object({
|
|
|
536
577
|
pushSummary: z.any().volatile(),
|
|
537
578
|
pushTurnEnd: z.any().volatile(),
|
|
538
579
|
pushTool: z.any().volatile(),
|
|
580
|
+
// Push-leg master switch and session-name line (P55-1): apply() wires its
|
|
581
|
+
// listeners and the push_notify tool from them, so both reload the row on
|
|
582
|
+
// change (RESTART_FIELDS).
|
|
583
|
+
pushEnabled: z.any().volatile(),
|
|
584
|
+
pushSessionName: z.any().volatile(),
|
|
539
585
|
serverName: z.any().volatile(),
|
|
540
586
|
idleHours: z.any().volatile(),
|
|
541
587
|
autoShareNewSessions: z.any().volatile(),
|