dsh-zen-remote 2.0.3 → 2.2.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/AGENTS.md +15 -1
- package/README.md +11 -5
- package/README.zh-CN.md +11 -5
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +15 -0
- package/docs/adr/0002-relay-emulates-websocket.md +8 -0
- package/docs/interface.md +3 -1
- package/docs/plugin-bridge.md +140 -0
- package/docs/remote-access.en.md +57 -0
- package/docs/remote-access.md +57 -0
- package/lib/adapters/dsh-better-sidebar/index.js +144 -0
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
- package/lib/adapters/dsh-better-sidebar/methods.js +198 -0
- package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
- package/lib/adapters/index.js +17 -0
- package/lib/adapters/index.js.map +1 -0
- package/lib/adapters/types.js +17 -0
- package/lib/adapters/types.js.map +1 -0
- package/lib/admin-routes.js +51 -0
- package/lib/admin-routes.js.map +1 -1
- package/lib/client-routes.js +18 -1
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +449 -45
- package/lib/config.js +27 -0
- package/lib/config.js.map +1 -1
- package/lib/http.js +10 -0
- package/lib/http.js.map +1 -1
- package/lib/index.js +246 -0
- package/lib/index.js.map +1 -1
- package/lib/plugin-adapter-core.js +385 -0
- package/lib/plugin-adapter-core.js.map +1 -0
- package/lib/plugin-bridge.js +318 -0
- package/lib/plugin-bridge.js.map +1 -0
- package/lib/plugin-loopback.js +207 -0
- package/lib/plugin-loopback.js.map +1 -0
- package/lib/plugin-route-intercept.js +1103 -0
- package/lib/plugin-route-intercept.js.map +1 -0
- package/lib/plugin-tunnel.js +416 -0
- package/lib/plugin-tunnel.js.map +1 -0
- package/lib/relay-client.js +319 -2
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-server.js +633 -0
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +51 -0
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +17 -0
- package/lib/types/adapters/index.d.ts.map +1 -0
- package/lib/types/adapters/types.d.ts +42 -0
- package/lib/types/adapters/types.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +16 -0
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts +0 -22
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +16 -0
- 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/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-data/plugin-bridge.d.ts +79 -0
- package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +20 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +37 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +13 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +8 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/http.d.ts +7 -0
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/index.d.ts +4 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts +263 -0
- package/lib/types/plugin-adapter-core.d.ts.map +1 -0
- package/lib/types/plugin-bridge.d.ts +187 -0
- package/lib/types/plugin-bridge.d.ts.map +1 -0
- package/lib/types/plugin-loopback.d.ts +104 -0
- package/lib/types/plugin-loopback.d.ts.map +1 -0
- package/lib/types/plugin-route-intercept.d.ts +114 -0
- package/lib/types/plugin-route-intercept.d.ts.map +1 -0
- package/lib/types/plugin-tunnel.d.ts +96 -0
- package/lib/types/plugin-tunnel.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +76 -0
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts +32 -1
- package/lib/types/relay-server.d.ts.map +1 -1
- package/llms.txt +1 -0
- package/package.json +2 -2
- package/scripts/check-plugin-bridge-ui.mjs +168 -0
- package/scripts/check-settings-form.mjs +3 -1
- package/src/adapters/dsh-better-sidebar/index.ts +144 -0
- package/src/adapters/dsh-better-sidebar/methods.ts +215 -0
- package/src/adapters/index.ts +19 -0
- package/src/adapters/types.ts +44 -0
- package/src/admin-routes.ts +64 -0
- package/src/client/effects/remote-session.ts +82 -10
- package/src/client/locales.ts +36 -0
- package/src/client/settings/SettingsSection.tsx +107 -3
- package/src/client/styles/remote-session.css.ts +70 -31
- package/src/client-data/plugin-bridge.ts +150 -0
- package/src/client-data/remote-status.ts +30 -0
- package/src/client-data/settings-form.ts +78 -0
- package/src/client-routes.ts +29 -1
- package/src/config.ts +32 -0
- package/src/http.ts +12 -0
- package/src/index.ts +252 -0
- package/src/plugin-adapter-core.ts +530 -0
- package/src/plugin-bridge.ts +435 -0
- package/src/plugin-loopback.ts +299 -0
- package/src/plugin-route-intercept.ts +1193 -0
- package/src/plugin-tunnel.ts +470 -0
- package/src/relay-client.ts +394 -2
- package/src/relay-server.ts +656 -1
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { RESTART_FIELDS } from '../restart-fields.ts'
|
|
29
|
+
import { derivePluginBridgeStatuses, type PluginBridgeStatusView } from './plugin-bridge.ts'
|
|
29
30
|
|
|
30
31
|
// --- wire shapes -----------------------------------------------------------
|
|
31
32
|
|
|
@@ -88,6 +89,8 @@ export const ADMIN_STATUS_ROUTE = '/_dsh/zen-remote/admin/status'
|
|
|
88
89
|
export const ADMIN_PAIR_ROUTE = '/_dsh/zen-remote/admin/pair'
|
|
89
90
|
export const ADMIN_ACTION_ROUTE = '/_dsh/zen-remote/admin/action'
|
|
90
91
|
export const ADMIN_PUSH_TEST_ROUTE = '/_dsh/zen-remote/admin/push-test'
|
|
92
|
+
/** PB-5: the plugin bridge's registered-plugin listing (GET, server-local only). */
|
|
93
|
+
export const ADMIN_PLUGIN_BRIDGE_ROUTE = '/_dsh/zen-remote/admin/plugin-bridge'
|
|
91
94
|
|
|
92
95
|
/** Same-origin client routes the sub-client block talks to (host half: T16). */
|
|
93
96
|
export const CLIENT_CLAIM_ROUTE = '/_dsh/zen-remote/client/claim'
|
|
@@ -153,6 +156,13 @@ export interface ClientStatusBody {
|
|
|
153
156
|
* string[], unavailable: string[], incompatibleCalls: { time: number,
|
|
154
157
|
* endpoint: string, code: string }[] }` (presence-gated). */
|
|
155
158
|
compat?: unknown
|
|
159
|
+
/** PB-2: the per-plugin bridge verdicts (`BridgeStatus[]` verbatim,
|
|
160
|
+
* presence-gated — older servers answer without it). */
|
|
161
|
+
pluginBridge?: unknown
|
|
162
|
+
/** PB-4: the plugin-route interception's diagnostics (presence-gated —
|
|
163
|
+
* a host role or a refused install may still answer it; a server without
|
|
164
|
+
* the field never does). */
|
|
165
|
+
pluginRouteIntercept?: unknown
|
|
156
166
|
}
|
|
157
167
|
|
|
158
168
|
/** One remote-call failure line in the diagnostics lists. `time` is epoch
|
|
@@ -200,6 +210,13 @@ export interface ClientConnectionView {
|
|
|
200
210
|
deviceName: string
|
|
201
211
|
intercept: ClientInterceptView | undefined
|
|
202
212
|
compat: ClientCompatView | undefined
|
|
213
|
+
/** PB-5: the bridge verdicts as the 诊断区 renders them, input order kept
|
|
214
|
+
* (undefined when the body carried none — the group stays hidden). */
|
|
215
|
+
pluginBridge: PluginBridgeStatusView[] | undefined
|
|
216
|
+
/** PB-5: the route interception's install state (`installed: false`
|
|
217
|
+
* renders the 本机改道未启用 line with the shape reasons; undefined when
|
|
218
|
+
* the body carried none). */
|
|
219
|
+
pluginRouteIntercept: { installed: boolean; reasons: string[] } | undefined
|
|
203
220
|
}
|
|
204
221
|
|
|
205
222
|
const CLIENT_STATES: readonly ClientConnectionView['state'][] = ['unpaired', 'connected', 'revoked', 'unreachable', 'unexpected', 'invalid-url', 'incompatible']
|
|
@@ -258,6 +275,18 @@ function deriveInterceptView(value: unknown): ClientInterceptView | undefined {
|
|
|
258
275
|
}
|
|
259
276
|
}
|
|
260
277
|
|
|
278
|
+
/** PB-4 route-interception block, presence-gated the same way: only the
|
|
279
|
+
* install flag and the shape reasons (the 本机改道未启用 line) — the
|
|
280
|
+
* counters belong to the status surface, not this copy. */
|
|
281
|
+
function derivePluginRouteInterceptView(value: unknown): { installed: boolean; reasons: string[] } | undefined {
|
|
282
|
+
if (value === undefined || value === null) return undefined
|
|
283
|
+
const record = asRecord(value)
|
|
284
|
+
return {
|
|
285
|
+
installed: record.installed === true,
|
|
286
|
+
reasons: stringListOf(record.reasons, 20),
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
261
290
|
/** T42 compat block, only when the body carried the field at all. The wire
|
|
262
291
|
* names are the status route's (`different` / `incompatibleCalls`); the view
|
|
263
292
|
* keeps the render-side names the block was written against. */
|
|
@@ -302,6 +331,8 @@ export function deriveClientStatusView(body: ClientStatusBody): ClientConnection
|
|
|
302
331
|
deviceName: typeof safe.deviceName === 'string' ? safe.deviceName : '',
|
|
303
332
|
intercept: deriveInterceptView(safe.intercept),
|
|
304
333
|
compat: deriveCompatView(safe.compat),
|
|
334
|
+
pluginBridge: derivePluginBridgeStatuses(safe.pluginBridge),
|
|
335
|
+
pluginRouteIntercept: derivePluginRouteInterceptView(safe.pluginRouteIntercept),
|
|
305
336
|
}
|
|
306
337
|
}
|
|
307
338
|
|
|
@@ -641,6 +672,47 @@ export function boolField(field: string): SettingsFieldSpec {
|
|
|
641
672
|
}
|
|
642
673
|
}
|
|
643
674
|
|
|
675
|
+
/**
|
|
676
|
+
* `pluginBridgeConsent` (PB-5): a name → boolean map staged as its canonical
|
|
677
|
+
* JSON draft. Nothing renders it as text — the 插件桥 toggles stage the NEXT
|
|
678
|
+
* map wholesale (`togglePluginConsent` builds it, one key per click) — but
|
|
679
|
+
* riding the shared spec table is what puts the field on the SAME save path
|
|
680
|
+
* as `autoShareNewSessions`: one staged revision, one save button, one
|
|
681
|
+
* atomic mutation. `format` keeps the value's own key order so a staged
|
|
682
|
+
* draft equal to the displayed value reads as "no change"; an illegal stored
|
|
683
|
+
* map formats as `{}` (the toggle then writes from scratch, and the junk
|
|
684
|
+
* stays refused by the row validator, never laundered through).
|
|
685
|
+
*/
|
|
686
|
+
export function consentField(field: string): SettingsFieldSpec {
|
|
687
|
+
const format = (value: unknown): string => {
|
|
688
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return '{}'
|
|
689
|
+
const out: Record<string, boolean> = {}
|
|
690
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
691
|
+
if (typeof entry === 'boolean') out[key] = entry
|
|
692
|
+
}
|
|
693
|
+
return JSON.stringify(out)
|
|
694
|
+
}
|
|
695
|
+
return {
|
|
696
|
+
field,
|
|
697
|
+
format,
|
|
698
|
+
parse: (text) => {
|
|
699
|
+
let parsed: unknown
|
|
700
|
+
try {
|
|
701
|
+
parsed = JSON.parse(text)
|
|
702
|
+
} catch {
|
|
703
|
+
return undefined
|
|
704
|
+
}
|
|
705
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined
|
|
706
|
+
const out: Record<string, boolean> = {}
|
|
707
|
+
for (const [key, entry] of Object.entries(parsed as Record<string, unknown>)) {
|
|
708
|
+
if (typeof entry !== 'boolean') return undefined
|
|
709
|
+
out[key] = entry
|
|
710
|
+
}
|
|
711
|
+
return { kind: 'set', value: out }
|
|
712
|
+
},
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
644
716
|
/** Names of the row fields the settings page edits, in group order. */
|
|
645
717
|
export type SettingsFieldName =
|
|
646
718
|
| 'role'
|
|
@@ -657,6 +729,7 @@ export type SettingsFieldName =
|
|
|
657
729
|
| 'serverName'
|
|
658
730
|
| 'idleHours'
|
|
659
731
|
| 'autoShareNewSessions'
|
|
732
|
+
| 'pluginBridgeConsent'
|
|
660
733
|
|
|
661
734
|
/** Longest legal `serverName`, mirroring src/config.ts's cap. */
|
|
662
735
|
export const SERVER_NAME_MAX = 40
|
|
@@ -680,6 +753,7 @@ export const SETTINGS_FIELDS: readonly SettingsFieldSpec[] = [
|
|
|
680
753
|
nameField('serverName', SERVER_NAME_MAX),
|
|
681
754
|
hoursField('idleHours', IDLE_HOURS_MAX),
|
|
682
755
|
boolField('autoShareNewSessions'),
|
|
756
|
+
consentField('pluginBridgeConsent'),
|
|
683
757
|
]
|
|
684
758
|
|
|
685
759
|
// --- the status -> view mapping ---------------------------------------------
|
|
@@ -894,6 +968,10 @@ export interface ZenRemoteFormState extends SettingsFormShellState {
|
|
|
894
968
|
serverName: SettingsFieldState
|
|
895
969
|
idleHours: SettingsFieldState
|
|
896
970
|
autoShareNewSessions: SettingsFieldState
|
|
971
|
+
/** The consent map's staged state (PB-5). No text control renders it — the
|
|
972
|
+
* 插件桥 toggles stage the next map wholesale — but its `invalid` flag
|
|
973
|
+
* (a malformed draft) still gates the save like any field's. */
|
|
974
|
+
pluginBridgeConsent: SettingsFieldState
|
|
897
975
|
deviceToken: SettingsSecretFieldState
|
|
898
976
|
/** A restart-required field ({@link RESTART_FIELDS}) has a staged change a
|
|
899
977
|
* save would actually write: the save will move the row and the host
|
package/src/client-routes.ts
CHANGED
|
@@ -31,7 +31,9 @@ import type { ClaimOutcome, ProbeState } from './client-pairing.js'
|
|
|
31
31
|
import { unwrapVolatile } from './config.js'
|
|
32
32
|
import type { FetchRouteInterceptDiagnostics } from './fetch-route-intercept.js'
|
|
33
33
|
import type { CurrentSessionRead, InterceptDiagnostics } from './intercept.js'
|
|
34
|
+
import type { PluginRouteInterceptDiagnostics } from './plugin-route-intercept.js'
|
|
34
35
|
import { responseJson, sameOriginPost } from './http.js'
|
|
36
|
+
import type { BridgeStatus } from './plugin-bridge.js'
|
|
35
37
|
import { RELAY_HTTP_ROUTES } from './relay-access.js'
|
|
36
38
|
import { relayCredentialsDigest, RelayError } from './relay-client.js'
|
|
37
39
|
import type { RelayClient, RelayHttpResult } from './relay-client.js'
|
|
@@ -118,6 +120,17 @@ export interface ClientHandlerOptions {
|
|
|
118
120
|
* its refusal counters. Undefined when this composition never attempted
|
|
119
121
|
* one (no connection service, or no fileUploads service to inject). */
|
|
120
122
|
getFetchRouteIntercept?: () => FetchRouteInterceptDiagnostics | undefined
|
|
123
|
+
/** The plugin-route interception's diagnostics (PB-4), read live per
|
|
124
|
+
* request: the local webServer `match` wrap that forwards bridged-plugin
|
|
125
|
+
* requests for remote sessions. Undefined when this composition never
|
|
126
|
+
* attempted one (a host role, or no webServer). */
|
|
127
|
+
getPluginRouteIntercept?: () => PluginRouteInterceptDiagnostics | undefined
|
|
128
|
+
/** The plugin bridge's live verdict (PB-2), computed PER CALL off the local
|
|
129
|
+
* registry, the last handshake's `plugins` table and the connection state —
|
|
130
|
+
* never a stored conclusion. Wired only by the CLIENT role's mount; absent
|
|
131
|
+
* (the host mount, old tests) both routes answer the empty shape: status's
|
|
132
|
+
* `pluginBridge: []` and remote-status's `bridgedPlugins: []`. */
|
|
133
|
+
getPluginBridgeStatus?: () => BridgeStatus[]
|
|
121
134
|
/** Serve ONLY the remote-status route (T41a-fix2, the host role's mount):
|
|
122
135
|
* every other route under the prefix answers the same 404 an unknown path
|
|
123
136
|
* would. The claim / status / reconnect / unshare routes exist for a
|
|
@@ -456,6 +469,7 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
456
469
|
// that.
|
|
457
470
|
const intercept = options.getIntercept?.()
|
|
458
471
|
const fetchRouteIntercept = options.getFetchRouteIntercept?.()
|
|
472
|
+
const pluginRouteIntercept = options.getPluginRouteIntercept?.()
|
|
459
473
|
const compatRelay = options.getRelayClient?.()
|
|
460
474
|
const compat =
|
|
461
475
|
compatRelay?.compat !== undefined || (intercept?.incompatibleCalls.length ?? 0) > 0
|
|
@@ -467,8 +481,13 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
467
481
|
}
|
|
468
482
|
: undefined
|
|
469
483
|
const withDiagnostics = (body: Record<string, unknown>): Record<string, unknown> => {
|
|
470
|
-
|
|
484
|
+
// PB-2: the per-plugin bridge verdict rides EVERY status answer —
|
|
485
|
+
// the settings surface explains a not-bridged plugin whatever the
|
|
486
|
+
// connection state (unpaired reads all-offline, the honest verdict).
|
|
487
|
+
let next: Record<string, unknown> = { ...body, pluginBridge: options.getPluginBridgeStatus?.() ?? [] }
|
|
488
|
+
if (intercept !== undefined) next = { ...next, intercept }
|
|
471
489
|
if (fetchRouteIntercept !== undefined) next = { ...next, fetchRouteIntercept }
|
|
490
|
+
if (pluginRouteIntercept !== undefined) next = { ...next, pluginRouteIntercept }
|
|
472
491
|
return compat === undefined ? next : { ...next, compat }
|
|
473
492
|
}
|
|
474
493
|
// The ROW decides its own two shapes first, per request (both fields
|
|
@@ -571,6 +590,11 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
571
590
|
// T34: the T34 client parts' data source — no token, no server
|
|
572
591
|
// address, nothing but the serving state, the compat verdict's
|
|
573
592
|
// difference flag, the display name and the closed-session map.
|
|
593
|
+
// PB-2 adds `bridgedPlugins`: the names whose bridge verdict is
|
|
594
|
+
// bridged RIGHT NOW, sorted (the list arrives plugin-sorted, but the
|
|
595
|
+
// sort is pinned here so the field's shape never depends on the
|
|
596
|
+
// registry's internal order). The host mount — no verdict source —
|
|
597
|
+
// answers [].
|
|
574
598
|
const relay = options.getRelayClient?.()
|
|
575
599
|
const intercept = options.getIntercept?.()
|
|
576
600
|
responseJson(res, 200, {
|
|
@@ -578,6 +602,10 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
578
602
|
versionMismatch: (relay?.compat?.different?.length ?? 0) > 0,
|
|
579
603
|
serverName: relay?.handshakeInfo?.serverName ?? '',
|
|
580
604
|
closed: Object.fromEntries((intercept?.closedSessions ?? []).map((record) => [record.sessionId, record.reason])),
|
|
605
|
+
bridgedPlugins: (options.getPluginBridgeStatus?.() ?? [])
|
|
606
|
+
.filter((status) => status.bridged)
|
|
607
|
+
.map((status) => status.plugin)
|
|
608
|
+
.sort(),
|
|
581
609
|
})
|
|
582
610
|
return
|
|
583
611
|
}
|
package/src/config.ts
CHANGED
|
@@ -77,6 +77,12 @@ export interface ZenRemoteConfig {
|
|
|
77
77
|
idleHours: number
|
|
78
78
|
/** Auto-share every newly created session to the paired phone (T16). */
|
|
79
79
|
autoShareNewSessions: boolean
|
|
80
|
+
/** Per-plugin consent for bridging whole-machine data (plugin bridge,
|
|
81
|
+
* PB-1): plugin name → whether the server owner lets that plugin's
|
|
82
|
+
* `scope:'server'` routes be forwarded inside remote sessions. Absent or
|
|
83
|
+
* false means NO — default `{}` keeps every plugin unconsented. Volatile:
|
|
84
|
+
* read at compare time, deliberately NOT in RESTART_FIELDS. */
|
|
85
|
+
pluginBridgeConsent: Record<string, boolean>
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
function defaultServerName(): string {
|
|
@@ -101,6 +107,7 @@ export const DEFAULTS: ZenRemoteConfig = {
|
|
|
101
107
|
serverName: defaultServerName(),
|
|
102
108
|
idleHours: 48,
|
|
103
109
|
autoShareNewSessions: false,
|
|
110
|
+
pluginBridgeConsent: {},
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
// --- per-field validators -------------------------------------------------
|
|
@@ -176,6 +183,22 @@ function serverNameOf(value: unknown): string | undefined {
|
|
|
176
183
|
return name === undefined ? undefined : name.slice(0, SERVER_NAME_MAX)
|
|
177
184
|
}
|
|
178
185
|
|
|
186
|
+
/** `pluginBridgeConsent`: a plain object mapping plugin names to STRICT
|
|
187
|
+
* booleans. Anything else — null, an array, a non-boolean value (`"yes"`,
|
|
188
|
+
* `1`; unlike the scalar booleans there is no historic `1`/`"1"` convention
|
|
189
|
+
* to honor, this map only ever came from the settings surface or a
|
|
190
|
+
* hand-edited row/file) — makes the layer transparent, because a half-valid
|
|
191
|
+
* consent map must never read as "consented". Object keys are strings by
|
|
192
|
+
* construction in both JSON and YAML; the per-key string check stays as the
|
|
193
|
+
* cheap belt over the iteration. */
|
|
194
|
+
function consentMap(value: unknown): Record<string, boolean> | undefined {
|
|
195
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
196
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
197
|
+
if (typeof key !== 'string' || typeof entry !== 'boolean') return undefined
|
|
198
|
+
}
|
|
199
|
+
return value as Record<string, boolean>
|
|
200
|
+
}
|
|
201
|
+
|
|
179
202
|
/** Env validator applied to `Number(env[name])`, so `LAN_GATE_PORT='abc'`
|
|
180
203
|
* lands in the same "unset" bucket as a bad row value. */
|
|
181
204
|
function envNumber(
|
|
@@ -263,6 +286,10 @@ const FIELDS: { [K in keyof ZenRemoteConfig]: FieldResolver<ZenRemoteConfig[K]>
|
|
|
263
286
|
serverName: { row: serverNameOf, file: serverNameOf },
|
|
264
287
|
idleHours: { row: inRange(0, 8760), file: inRange(0, 8760) },
|
|
265
288
|
autoShareNewSessions: { row: boolLike, file: boolLike },
|
|
289
|
+
// No env layer on purpose (PB-1): a per-plugin consent map has no historic
|
|
290
|
+
// flat variable to stay compatible with, and a consent decision is a
|
|
291
|
+
// settings-page action, not something set through the environment.
|
|
292
|
+
pluginBridgeConsent: { row: consentMap, file: consentMap },
|
|
266
293
|
}
|
|
267
294
|
|
|
268
295
|
/**
|
|
@@ -346,6 +373,7 @@ export function resolveConfig(
|
|
|
346
373
|
serverName: resolveField('serverName', safeRow, safeFile, env, sources),
|
|
347
374
|
idleHours: resolveField('idleHours', safeRow, safeFile, env, sources),
|
|
348
375
|
autoShareNewSessions: resolveField('autoShareNewSessions', safeRow, safeFile, env, sources),
|
|
376
|
+
pluginBridgeConsent: resolveField('pluginBridgeConsent', safeRow, safeFile, env, sources),
|
|
349
377
|
}
|
|
350
378
|
return { values, sources }
|
|
351
379
|
}
|
|
@@ -432,6 +460,10 @@ export const Config = z.object({
|
|
|
432
460
|
serverName: z.any().volatile(),
|
|
433
461
|
idleHours: z.any().volatile(),
|
|
434
462
|
autoShareNewSessions: z.any().volatile(),
|
|
463
|
+
// Plugin-bridge consent (PB-1): a name → boolean map, live-read at compare
|
|
464
|
+
// time — a consent flip must apply without a row restart, so it is
|
|
465
|
+
// deliberately NOT in RESTART_FIELDS.
|
|
466
|
+
pluginBridgeConsent: z.any().volatile(),
|
|
435
467
|
// Client-half knobs (T16): where the phone finds the gateway, and the
|
|
436
468
|
// pairing token it presents. `role('secret')` keeps the token out of every
|
|
437
469
|
// settings wire surface, like the verifier plugin's API key.
|
package/src/http.ts
CHANGED
|
@@ -66,3 +66,15 @@ export function sameOriginPost(req: IncomingMessage): boolean {
|
|
|
66
66
|
return false
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One plugin-bridge refusal (PB-3): the standard JSON envelope plus the
|
|
73
|
+
* `x-zen-remote-error` marker header — the sub-client's passthrough layer
|
|
74
|
+
* classifies a refusal off the header alone, because the success path
|
|
75
|
+
* streams opaque plugin bytes and must never have to parse a body.
|
|
76
|
+
*/
|
|
77
|
+
export function pluginBridgeError(res: ServerResponse, status: number, code: string): void {
|
|
78
|
+
res.setHeader('x-zen-remote-error', code)
|
|
79
|
+
responseJson(res, status, { ok: false, error: { code } })
|
|
80
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -59,8 +59,22 @@ import { checkGatewayShape } from './intercept-shape.js'
|
|
|
59
59
|
import type { GatewayShapeCheck } from './intercept-shape.js'
|
|
60
60
|
import { checkFetchRouteShape, installFetchRouteIntercept } from './fetch-route-intercept.js'
|
|
61
61
|
import type { FetchRouteInterceptDiagnostics, FetchRouteInterceptHandle, FetchRouteShapeCheck } from './fetch-route-intercept.js'
|
|
62
|
+
import { checkWebServerShape, installPluginRouteIntercept } from './plugin-route-intercept.js'
|
|
63
|
+
import type { PluginRouteInterceptDiagnostics, PluginRouteInterceptHandle, WebServerShapeCheck } from './plugin-route-intercept.js'
|
|
64
|
+
import { normalizeAdapter, adapterOwnsRegistryEntry } from './plugin-adapter-core.js'
|
|
65
|
+
import type { NormalizedAdapter } from './plugin-adapter-core.js'
|
|
66
|
+
import { ADAPTERS } from './adapters/index.js'
|
|
62
67
|
import { createRelayHandler, loadServerId, RELAY_PREFIX, resolveDshVersion } from './relay-server.js'
|
|
63
68
|
import type { RelayGateway, RelayHandler } from './relay-server.js'
|
|
69
|
+
import { createBridgeRegistry, compareBridges } from './plugin-bridge.js'
|
|
70
|
+
import type { BridgeDeclaration, BridgeRegistry, BridgeStatus, ServerPluginEntry, ZenRemoteService } from './plugin-bridge.js'
|
|
71
|
+
import { createPluginLoopback } from './plugin-loopback.js'
|
|
72
|
+
import { createPluginTunnel } from './plugin-tunnel.js'
|
|
73
|
+
|
|
74
|
+
// The plugin bridge's public types (PB-1), re-exported so another plugin's
|
|
75
|
+
// author can `import type { … } from 'dsh-zen-remote'` without reaching into
|
|
76
|
+
// lib/types. Type-only: no runtime code, no Context augmentation.
|
|
77
|
+
export type { BridgeDeclaration, ZenRemoteService } from './plugin-bridge.js'
|
|
64
78
|
|
|
65
79
|
// The loader's config schema (settings form) and the role normalizer live in
|
|
66
80
|
// src/config.ts next to the resolution they describe; re-exported so the
|
|
@@ -126,6 +140,22 @@ export function getRelayClient(): RelayClient | undefined {
|
|
|
126
140
|
return clientRelayClient
|
|
127
141
|
}
|
|
128
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The plugin bridge's declaration registry (PB-1), created inside apply() on
|
|
145
|
+
* BOTH roles and kept on the module so later tasks (the handshake's plugins
|
|
146
|
+
* table, the client-side rewrite) can reach it without reaching into a
|
|
147
|
+
* context. Undefined in compositions that never ran this module's apply, and
|
|
148
|
+
* — identity-guarded below — after the row that owned a registry was
|
|
149
|
+
* unloaded or reloaded, so a reloaded row never leaves a stale table where
|
|
150
|
+
* the getter can hand it out (the T23a-fix discipline).
|
|
151
|
+
*/
|
|
152
|
+
let bridgeRegistry: BridgeRegistry | undefined
|
|
153
|
+
|
|
154
|
+
/** The live bridge registry, if a plugin row is currently applied. */
|
|
155
|
+
export function getBridgeRegistry(): BridgeRegistry | undefined {
|
|
156
|
+
return bridgeRegistry
|
|
157
|
+
}
|
|
158
|
+
|
|
129
159
|
/** Host half config. Every field below arrives through the loader as a
|
|
130
160
|
* volatile live reference — a `{ get() }` wrapper over the stored value, not
|
|
131
161
|
* the value itself (src/config.ts explains why every field is volatile) — so
|
|
@@ -461,6 +491,76 @@ export async function handleUpload(
|
|
|
461
491
|
*/
|
|
462
492
|
export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
463
493
|
const effective = resolveConfig(config, readFileConfig(), process.env)
|
|
494
|
+
// The plugin bridge's zenRemote service (PB-1), provided on BOTH roles
|
|
495
|
+
// before any role branch: a plugin that wants its panel data forwarded in
|
|
496
|
+
// remote sessions declares that against THIS process whichever role it
|
|
497
|
+
// runs as, and the declaration registry is the one piece of PB-1 both the
|
|
498
|
+
// server half (handshake plugins table) and the client half (route
|
|
499
|
+
// rewrite) will read. ctx.provide (cordis 4) registers the service owned
|
|
500
|
+
// by THIS row's fiber: visible to `inject(['zenRemote'])` dependents once
|
|
501
|
+
// the row is active, withdrawn when the row unloads — so a composition
|
|
502
|
+
// without zen-remote never fires those injects and a row reload swaps the
|
|
503
|
+
// service wholesale. The registry goes into the module slot BEFORE the
|
|
504
|
+
// provide so this apply's consumers never see a window without it; the
|
|
505
|
+
// effect's identity guard then clears the slot exactly when THIS row
|
|
506
|
+
// unloads (a reload installs the new registry before the old disposer
|
|
507
|
+
// runs, same shape as clientRelayClient). provide() throwing is not worth
|
|
508
|
+
// failing the row over — the bridge stays off, everything else keeps
|
|
509
|
+
// working — so it degrades to a warning like the session feeds do.
|
|
510
|
+
const registry = createBridgeRegistry({
|
|
511
|
+
warn: (text) => { ctx.logger?.warn('dsh-zen-remote plugin bridge: %s', text) },
|
|
512
|
+
})
|
|
513
|
+
bridgeRegistry = registry
|
|
514
|
+
try {
|
|
515
|
+
const service: ZenRemoteService = {
|
|
516
|
+
bridge: (declaration: BridgeDeclaration) => registry.register(declaration),
|
|
517
|
+
}
|
|
518
|
+
ctx.provide('zenRemote', service)
|
|
519
|
+
} catch (error) {
|
|
520
|
+
ctx.logger?.warn('dsh-zen-remote: cannot provide the "zenRemote" service: %s', message(error))
|
|
521
|
+
}
|
|
522
|
+
ctx.effect(() => () => {
|
|
523
|
+
if (bridgeRegistry === registry) bridgeRegistry = undefined
|
|
524
|
+
}, 'dsh-zen-remote: plugin bridge registry disposal')
|
|
525
|
+
// The adapters (PB-11): each one registers ONLY against its target's
|
|
526
|
+
// EXACT installed version, probed off the host's `pluginPackages` service
|
|
527
|
+
// (`packageOf(name, ctx.baseUrl)` — verified on 0.2.0-rc.2 to answer
|
|
528
|
+
// `{name, version, dir, …}` or undefined; any other shape, a missing
|
|
529
|
+
// service or a probe failure reads as "not installed" and the adapter
|
|
530
|
+
// stays out). A plugin that registers its OWN cooperative declaration
|
|
531
|
+
// always wins: the adapter yields at registration time (logged), and the
|
|
532
|
+
// runtime re-checks the registry digest on every request. Both roles run
|
|
533
|
+
// this loop — the registry entries drive the handshake table, the bridge
|
|
534
|
+
// verdicts and the settings listing on either side, while the returned
|
|
535
|
+
// array feeds the two request halves.
|
|
536
|
+
const adapters: NormalizedAdapter[] = []
|
|
537
|
+
{
|
|
538
|
+
const adapterWarn = (text: string): void => { ctx.logger?.warn('dsh-zen-remote adapter: %s', text) }
|
|
539
|
+
for (const adapter of ADAPTERS) {
|
|
540
|
+
try {
|
|
541
|
+
const holder = ctx as unknown as { get?: (name: string) => unknown; baseUrl?: string }
|
|
542
|
+
const packages = holder.get?.('pluginPackages') as { packageOf?: (specifier: string, parentURL: string) => unknown } | undefined
|
|
543
|
+
const found = packages?.packageOf?.(adapter.plugin, holder.baseUrl ?? '') as { version?: unknown } | undefined
|
|
544
|
+
const installedVersion = found !== null && typeof found === 'object' && typeof (found as { version?: unknown }).version === 'string'
|
|
545
|
+
? (found as { version: string }).version
|
|
546
|
+
: undefined
|
|
547
|
+
if (installedVersion === undefined || installedVersion === '') {
|
|
548
|
+
adapterWarn(`${adapter.plugin}: target package (or its version) not found; not registered`)
|
|
549
|
+
continue
|
|
550
|
+
}
|
|
551
|
+
const normalized = normalizeAdapter(adapter, installedVersion)
|
|
552
|
+
if (registry.get(normalized.plugin) !== undefined) {
|
|
553
|
+
adapterWarn(`${normalized.plugin}: the plugin registers its own bridge declaration; the adapter yields`)
|
|
554
|
+
continue
|
|
555
|
+
}
|
|
556
|
+
registry.registerNormalized(normalized.declaration)
|
|
557
|
+
adapters.push(normalized)
|
|
558
|
+
ctx.logger?.info('dsh-zen-remote adapter: %s adapted at version %s', normalized.plugin, installedVersion)
|
|
559
|
+
} catch (error) {
|
|
560
|
+
adapterWarn(`${adapter.plugin} refused: ${error instanceof Error ? error.message : String(error)}`)
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
464
564
|
if (effective.values.role === 'host') {
|
|
465
565
|
// A row that switched from client to host must not leave a stale relay
|
|
466
566
|
// client where getRelayClient() can find it (T23a-fix).
|
|
@@ -597,6 +697,16 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
597
697
|
// composition lacks the service.
|
|
598
698
|
viewerCount: (id) => relayHandler?.viewerCount(id) ?? 0,
|
|
599
699
|
sessionQuery: () => webCtx.reflect.get('sessionQuery') as SessionQueryLike | undefined,
|
|
700
|
+
// The plugin bridge listing (PB-5): the SAME registry this apply
|
|
701
|
+
// built and the SAME live consent reader the relay's plugins table
|
|
702
|
+
// uses — one source of truth per question. PB-11: the listing
|
|
703
|
+
// marks the plugins an adapter stands in for (live — a plugin
|
|
704
|
+
// that registered its own declaration stops being marked).
|
|
705
|
+
pluginBridge: {
|
|
706
|
+
registry,
|
|
707
|
+
getConsent: () => resolveConfig(config, readFileConfig(), process.env).values.pluginBridgeConsent,
|
|
708
|
+
adaptedPlugins: () => new Set(adapters.filter((adapter) => adapterOwnsRegistryEntry(adapter, registry.get(adapter.plugin))).map((adapter) => adapter.plugin)),
|
|
709
|
+
},
|
|
600
710
|
}),
|
|
601
711
|
}), 'dsh-zen-remote: admin routes')
|
|
602
712
|
})
|
|
@@ -645,6 +755,32 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
645
755
|
// ~7 ms per computation against a full registry; a failed compute
|
|
646
756
|
// lands as the empty map inside the relay handler's own guard.
|
|
647
757
|
relayCtx.effect(() => {
|
|
758
|
+
// The plugin bridge's loopback facts (PB-3), both read PER REQUEST
|
|
759
|
+
// through the reflection layer: the web server may have restarted on
|
|
760
|
+
// another port or protocol, and the connection service's
|
|
761
|
+
// authenticatedUrl — the token-exchange entry the lan-gate wiring
|
|
762
|
+
// also reads (0.1.1 hosts lack the method entirely) — lives on a
|
|
763
|
+
// service deliberately NOT in this inject's declaration list (same
|
|
764
|
+
// reason as getApiFetch above: adding it would keep the whole relay
|
|
765
|
+
// prefix off compositions that lack it).
|
|
766
|
+
const getLoopbackTarget = () => {
|
|
767
|
+
const webServer = relayCtx.reflect.get('webServer') as { protocol?: unknown; port?: unknown } | undefined
|
|
768
|
+
const port = typeof webServer?.port === 'number' && webServer.port > 0 ? webServer.port : undefined
|
|
769
|
+
if (port === undefined) throw new Error('the web server exposes no loopback port')
|
|
770
|
+
return { protocol: webServer?.protocol === 'https:' ? ('https:' as const) : ('http:' as const), port }
|
|
771
|
+
}
|
|
772
|
+
const getTokenUrl = () => {
|
|
773
|
+
const connection = relayCtx.reflect.get('connection') as
|
|
774
|
+
| { authenticatedUrl?: (base: string) => string | undefined }
|
|
775
|
+
| undefined
|
|
776
|
+
if (typeof connection?.authenticatedUrl !== 'function') return undefined
|
|
777
|
+
const { protocol, port } = getLoopbackTarget()
|
|
778
|
+
return connection.authenticatedUrl(`${protocol}//127.0.0.1:${port}`)
|
|
779
|
+
}
|
|
780
|
+
// One loopback executor for BOTH plugin halves (the HTTP forward and
|
|
781
|
+
// the PB-10 tunnel): a single cookie cache behind two consumers.
|
|
782
|
+
const pluginLoopback = createPluginLoopback({ getTarget: getLoopbackTarget, getTokenUrl })
|
|
783
|
+
const pluginConsent = () => resolveConfig(config, readFileConfig(), process.env).values.pluginBridgeConsent
|
|
648
784
|
const handler = createRelayHandler({
|
|
649
785
|
secret: relaySecret,
|
|
650
786
|
store,
|
|
@@ -662,6 +798,43 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
662
798
|
serverName: () => resolveConfig(config, readFileConfig(), process.env).values.serverName,
|
|
663
799
|
dshVersion: resolveDshVersion(),
|
|
664
800
|
fingerprints: () => computeFingerprints(relayCtx),
|
|
801
|
+
// PB-2: the plugin bridge table, rebuilt FRESH at every handshake
|
|
802
|
+
// from the PB-1 registry and the row's consent map — both live:
|
|
803
|
+
// the registry through this apply's own closure, the consent
|
|
804
|
+
// through resolveConfig on the SAME row object (a volatile field,
|
|
805
|
+
// read at use time per the hard constraint, never snapshotted).
|
|
806
|
+
plugins: () => {
|
|
807
|
+
const consent = resolveConfig(config, readFileConfig(), process.env).values.pluginBridgeConsent
|
|
808
|
+
const table: Record<string, ServerPluginEntry> = {}
|
|
809
|
+
for (const declaration of registry.list()) {
|
|
810
|
+
table[declaration.plugin] = {
|
|
811
|
+
version: declaration.version,
|
|
812
|
+
digest: declaration.digest,
|
|
813
|
+
serverScopeConsent: consent[declaration.plugin] === true,
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return table
|
|
817
|
+
},
|
|
818
|
+
},
|
|
819
|
+
// PB-3: the forwarding half of the plugin bridge — the registry
|
|
820
|
+
// this apply built (the zenRemote service's table), the consent
|
|
821
|
+
// map read LIVE per request (a volatile row field), and the
|
|
822
|
+
// loopback executor over the wiring's two reflection getters.
|
|
823
|
+
// PB-10: the WebSocket byte tunnel's server half shares the SAME
|
|
824
|
+
// loopback (one cookie cache) and the same live consent; its
|
|
825
|
+
// reachability judgment rides the store directly (the handler's
|
|
826
|
+
// own isAccessible closure is not visible out here).
|
|
827
|
+
pluginBridge: {
|
|
828
|
+
registry,
|
|
829
|
+
getConsent: pluginConsent,
|
|
830
|
+
loopback: pluginLoopback,
|
|
831
|
+
adapters,
|
|
832
|
+
tunnel: createPluginTunnel({
|
|
833
|
+
bridge: { registry, getConsent: pluginConsent, loopback: pluginLoopback },
|
|
834
|
+
getTarget: getLoopbackTarget,
|
|
835
|
+
isAccessible: (sessionId) => store.isAccessible(sessionId, parentIndex.parentOf),
|
|
836
|
+
heartbeatMs: 15_000,
|
|
837
|
+
}),
|
|
665
838
|
},
|
|
666
839
|
})
|
|
667
840
|
const unregister = relayCtx.webServer.register({
|
|
@@ -735,6 +908,15 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
735
908
|
computeOwnFingerprints: () => computeFingerprints(ctx),
|
|
736
909
|
})
|
|
737
910
|
clientRelayClient = relayClient
|
|
911
|
+
// The plugin bridge's live verdict (PB-2), the CLIENT role's job — a host
|
|
912
|
+
// has no remote sessions of its own to bridge anything into. Computed PER
|
|
913
|
+
// CALL (never a stored conclusion): the local registry is this apply's
|
|
914
|
+
// own closure, the server's table is the LAST handshake's `plugins`
|
|
915
|
+
// (undefined until one succeeded — an old server reads server-too-old),
|
|
916
|
+
// and `online` is the client's CURRENT state — so a registry change, a
|
|
917
|
+
// fresh handshake or a link flip shows up at the very next read.
|
|
918
|
+
const pluginBridgeStatus = (): BridgeStatus[] =>
|
|
919
|
+
compareBridges(registry.list(), relayClient.handshakeInfo?.plugins, relayClient.state === 'online')
|
|
738
920
|
// A stopped or reloaded row must not leave a stale instance where the
|
|
739
921
|
// module getter can hand it out (T23a-fix), and its reconnect machinery
|
|
740
922
|
// (T43) must not outlive the row either: stop() cancels the armed retry
|
|
@@ -931,6 +1113,74 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
931
1113
|
if (fetchRouteHandle === handle) fetchRouteHandle = undefined
|
|
932
1114
|
}, 'dsh-zen-remote: fetch-route intercept')
|
|
933
1115
|
})
|
|
1116
|
+
// The plugin bridge's route interception (PB-4): the local webServer's
|
|
1117
|
+
// `match` wrap forwarding bridged-plugin requests for REMOTE sessions
|
|
1118
|
+
// through the relay. Injected on `webServer` alone — the only service
|
|
1119
|
+
// it reads — and the raw instance comes off the reflection layer's
|
|
1120
|
+
// symbols lookup, the same face the typert and fetch-route wraps use.
|
|
1121
|
+
// The diagnostics flow to the client status route like theirs, refused
|
|
1122
|
+
// installs included.
|
|
1123
|
+
let pluginRouteHandle: PluginRouteInterceptHandle | undefined
|
|
1124
|
+
let pluginRouteRefused: WebServerShapeCheck | undefined
|
|
1125
|
+
const pluginRouteDiagnostics = (): PluginRouteInterceptDiagnostics | undefined => {
|
|
1126
|
+
if (pluginRouteHandle !== undefined) return pluginRouteHandle.diagnostics()
|
|
1127
|
+
if (pluginRouteRefused !== undefined) {
|
|
1128
|
+
return {
|
|
1129
|
+
installed: false,
|
|
1130
|
+
shape: pluginRouteRefused,
|
|
1131
|
+
forwarded: 0,
|
|
1132
|
+
refused: 0,
|
|
1133
|
+
refusalsByReason: {},
|
|
1134
|
+
recentFailures: [],
|
|
1135
|
+
adapterForwarded: 0,
|
|
1136
|
+
adapterResponseRewrites: 0,
|
|
1137
|
+
adapterResponsePassthroughs: 0,
|
|
1138
|
+
tunnelsCurrent: 0,
|
|
1139
|
+
tunnelsOpened: 0,
|
|
1140
|
+
tunnelsClosed: 0,
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return undefined
|
|
1144
|
+
}
|
|
1145
|
+
ctx.inject(['webServer'], (wsCtx) => {
|
|
1146
|
+
const logWarn = (...args: Parameters<Context['logger']['warn']>): void => { ctx.logger?.warn(...args) }
|
|
1147
|
+
const proxy = (wsCtx as Context & { webServer?: unknown }).webServer
|
|
1148
|
+
const raw = proxy !== null && typeof proxy === 'object'
|
|
1149
|
+
? (proxy as unknown as Record<PropertyKey, unknown>)[symbols.original]
|
|
1150
|
+
: undefined
|
|
1151
|
+
if (raw === null || typeof raw !== 'object') {
|
|
1152
|
+
pluginRouteRefused = { ok: false, reasons: ['original: webServer[symbols.original] is not an object'] }
|
|
1153
|
+
logWarn('dsh-zen-remote plugin-route intercept not installed: %s', pluginRouteRefused.reasons[0])
|
|
1154
|
+
return
|
|
1155
|
+
}
|
|
1156
|
+
// Shape first, like every wrap: a failed check leaves the webServer
|
|
1157
|
+
// exactly as it was — local routing untouched, remote bridging off.
|
|
1158
|
+
const shape = checkWebServerShape(raw)
|
|
1159
|
+
if (!shape.ok) {
|
|
1160
|
+
pluginRouteRefused = shape
|
|
1161
|
+
logWarn('dsh-zen-remote plugin-route intercept not installed: %s', shape.reasons.join('; '))
|
|
1162
|
+
return
|
|
1163
|
+
}
|
|
1164
|
+
const handle = installPluginRouteIntercept({
|
|
1165
|
+
webServer: raw,
|
|
1166
|
+
registry,
|
|
1167
|
+
relay: relayClient,
|
|
1168
|
+
getServerId: () => relayClient.handshakeInfo?.serverId,
|
|
1169
|
+
getStatus: pluginBridgeStatus,
|
|
1170
|
+
adapters,
|
|
1171
|
+
// PB-12: the browser's currently open session — a VIRTUAL id means
|
|
1172
|
+
// a remote panel is the request's origin, the signal the adapter
|
|
1173
|
+
// routes' deny hooks read (better-sidebar's open.external carries
|
|
1174
|
+
// no session field of its own).
|
|
1175
|
+
getCurrentSession: () => (reportedCurrentSession.kind === 'open' ? reportedCurrentSession.sessionId : undefined),
|
|
1176
|
+
log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args) },
|
|
1177
|
+
})
|
|
1178
|
+
pluginRouteHandle = handle
|
|
1179
|
+
wsCtx.effect(() => () => {
|
|
1180
|
+
handle.uninstall()
|
|
1181
|
+
if (pluginRouteHandle === handle) pluginRouteHandle = undefined
|
|
1182
|
+
}, 'dsh-zen-remote: plugin route intercept')
|
|
1183
|
+
})
|
|
934
1184
|
ctx.inject(['webServer', 'connection'], (clientCtx) => {
|
|
935
1185
|
clientCtx.effect(() => clientCtx.webServer.register({
|
|
936
1186
|
kind: 'prefix',
|
|
@@ -941,6 +1191,8 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
941
1191
|
getRelayClient: () => relayClient,
|
|
942
1192
|
getIntercept: interceptDiagnostics,
|
|
943
1193
|
getFetchRouteIntercept: fetchRouteDiagnostics,
|
|
1194
|
+
getPluginRouteIntercept: pluginRouteDiagnostics,
|
|
1195
|
+
getPluginBridgeStatus: pluginBridgeStatus,
|
|
944
1196
|
onCurrentSession,
|
|
945
1197
|
}),
|
|
946
1198
|
}), 'dsh-zen-remote: client routes')
|