dsh-zen-remote 2.3.6 → 2.3.8
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 -5
- package/CHANGELOG.md +83 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/docs/interface.md +10 -4
- package/docs/remote-access.en.md +6 -0
- package/docs/remote-access.md +6 -0
- package/lan-gate.mjs +7 -0
- package/lib/client-pairing.js +10 -0
- package/lib/client-pairing.js.map +1 -1
- package/lib/client-routes.js +183 -94
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +505 -78
- package/lib/debug-redact.js +253 -0
- package/lib/debug-redact.js.map +1 -0
- package/lib/debug-report.js +60 -0
- package/lib/debug-report.js.map +1 -0
- package/lib/fetch-route-intercept.js +23 -0
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +293 -45
- package/lib/index.js.map +1 -1
- package/lib/log-ring.js +76 -0
- package/lib/log-ring.js.map +1 -0
- package/lib/merge-streams.js +55 -19
- package/lib/merge-streams.js.map +1 -1
- package/lib/relay-filter.js +4 -3
- package/lib/relay-filter.js.map +1 -1
- package/lib/relay-server.js +5 -3
- package/lib/relay-server.js.map +1 -1
- package/lib/types/client/effects/remote-drop.d.ts +20 -8
- package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +8 -1
- 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/misc.css.d.ts +1 -1
- package/lib/types/client/styles/misc.css.d.ts.map +1 -1
- package/lib/types/client-data/remote-drop.d.ts +64 -0
- package/lib/types/client-data/remote-drop.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +3 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-pairing.d.ts +8 -0
- package/lib/types/client-pairing.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +23 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/debug-redact.d.ts +68 -0
- package/lib/types/debug-redact.d.ts.map +1 -0
- package/lib/types/debug-report.d.ts +58 -0
- package/lib/types/debug-report.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +26 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +7 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/log-ring.d.ts +57 -0
- package/lib/types/log-ring.d.ts.map +1 -0
- package/lib/types/merge-streams.d.ts +27 -0
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/relay-filter.d.ts +4 -3
- package/lib/types/relay-filter.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts.map +1 -1
- package/package.json +6 -3
- package/pwa/app.css +13 -2
- package/scripts/check-debug-report.mjs +277 -0
- package/scripts/check-remote-drop.mjs +130 -0
- package/scripts/check-tablet-safe-area.mjs +49 -0
- package/src/client/effects/remote-drop.ts +271 -24
- package/src/client/locales.ts +20 -4
- package/src/client/settings/SettingsSection.tsx +120 -3
- package/src/client/styles/misc.css.ts +38 -0
- package/src/client-data/remote-drop.ts +103 -0
- package/src/client-data/settings-form.ts +3 -0
- package/src/client-pairing.ts +11 -0
- package/src/client-routes.ts +196 -90
- package/src/debug-redact.ts +290 -0
- package/src/debug-report.ts +93 -0
- package/src/fetch-route-intercept.ts +29 -0
- package/src/index.ts +282 -43
- package/src/log-ring.ts +101 -0
- package/src/merge-streams.ts +71 -16
- package/src/relay-filter.ts +4 -3
- package/src/relay-server.ts +5 -3
package/src/index.ts
CHANGED
|
@@ -59,7 +59,7 @@ import { runSelfCheck, installIntercept } from './intercept.js'
|
|
|
59
59
|
import type { CurrentSessionRead, InterceptDiagnostics, InterceptHandle, SessionInventorySlot } from './intercept.js'
|
|
60
60
|
import { checkGatewayShape } from './intercept-shape.js'
|
|
61
61
|
import type { GatewayShapeCheck } from './intercept-shape.js'
|
|
62
|
-
import { checkFetchRouteShape, installFetchRouteIntercept } from './fetch-route-intercept.js'
|
|
62
|
+
import { checkFetchRouteShape, installFetchRouteIntercept, shouldFallbackInstall } from './fetch-route-intercept.js'
|
|
63
63
|
import type { FetchRouteInterceptDiagnostics, FetchRouteInterceptHandle, FetchRouteShapeCheck } from './fetch-route-intercept.js'
|
|
64
64
|
import { checkWebServerShape, installPluginRouteIntercept } from './plugin-route-intercept.js'
|
|
65
65
|
import type { PluginRouteInterceptDiagnostics, PluginRouteInterceptHandle, WebServerShapeCheck } from './plugin-route-intercept.js'
|
|
@@ -87,6 +87,7 @@ import { createVerificationManager } from './wizard/verify.js'
|
|
|
87
87
|
import type { BridgeDeclaration, BridgeRegistry, BridgeStatus, ServerPluginEntry, ZenRemoteService } from './plugin-bridge.js'
|
|
88
88
|
import { createPluginLoopback } from './plugin-loopback.js'
|
|
89
89
|
import { createPluginTunnel } from './plugin-tunnel.js'
|
|
90
|
+
import { createLogRing, createPluginLog } from './log-ring.js'
|
|
90
91
|
import { DEFAULT_MAX_UPLOAD_BYTES } from './upload-limits.js'
|
|
91
92
|
|
|
92
93
|
// The plugin bridge's public types (PB-1), re-exported so another plugin's
|
|
@@ -112,6 +113,19 @@ import * as push from '../dsh-push.mjs'
|
|
|
112
113
|
/** Exact route the phone composer POSTs one file body to. */
|
|
113
114
|
export const UPLOAD_ROUTE = '/_dsh/mobile-nav/upload'
|
|
114
115
|
|
|
116
|
+
/**
|
|
117
|
+
* This package's own version, read once off the package root (`../package.json`
|
|
118
|
+
* resolves there both from src/ under the type-stripping scripts and from the
|
|
119
|
+
* built lib/ in production). The debug report's `_meta` plate carries it —
|
|
120
|
+
* issue triage starts at "which build is this" — and the row-apply log line
|
|
121
|
+
* stamps it. Unreadable file degrades to `'unknown'`, never throws.
|
|
122
|
+
*/
|
|
123
|
+
let pluginVersion = 'unknown'
|
|
124
|
+
try {
|
|
125
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version?: unknown }
|
|
126
|
+
if (typeof pkg?.version === 'string' && pkg.version !== '') pluginVersion = pkg.version
|
|
127
|
+
} catch { /* keep 'unknown' */ }
|
|
128
|
+
|
|
115
129
|
/**
|
|
116
130
|
* The one piece of cordis the typert interception needs at RUNTIME: the
|
|
117
131
|
* registered symbol under which every cordis proxy answers with its raw
|
|
@@ -457,6 +471,12 @@ async function writeUpload(
|
|
|
457
471
|
* request must ask the connection service itself, its refusal relayed
|
|
458
472
|
* verbatim — same first wall as the admin and client routes. Optional only
|
|
459
473
|
* for the direct-drive check scripts; production always passes it.
|
|
474
|
+
* @param warn - where the rejection line goes. apply() passes its double-
|
|
475
|
+
* writer (context logger + the CU-1 log ring), so a rejected upload — the
|
|
476
|
+
* very path issue #49 is triaged on — shows up in the debug report. The
|
|
477
|
+
* ring is per-apply on purpose (a row reload clears it), which is why it
|
|
478
|
+
* arrives as a parameter instead of a module singleton. Defaults to the
|
|
479
|
+
* context logger for the direct-drive check scripts.
|
|
460
480
|
*/
|
|
461
481
|
export async function handleUpload(
|
|
462
482
|
ctx: Context,
|
|
@@ -464,6 +484,7 @@ export async function handleUpload(
|
|
|
464
484
|
req: IncomingMessage,
|
|
465
485
|
res: ServerResponse,
|
|
466
486
|
admit?: (req: IncomingMessage) => AdminAdmission,
|
|
487
|
+
warn?: (format: string, ...args: unknown[]) => void,
|
|
467
488
|
): Promise<void> {
|
|
468
489
|
if (admit !== undefined) {
|
|
469
490
|
const admission = admit(req)
|
|
@@ -502,7 +523,8 @@ export async function handleUpload(
|
|
|
502
523
|
} catch (error) {
|
|
503
524
|
const status = error instanceof UploadError ? error.status : 400
|
|
504
525
|
const code = error instanceof UploadError ? error.code : 'upload-rejected'
|
|
505
|
-
|
|
526
|
+
if (warn !== undefined) warn('dsh-mobile-nav upload rejected: %s', message(error))
|
|
527
|
+
else ctx.logger.warn('dsh-mobile-nav upload rejected: %s', message(error))
|
|
506
528
|
responseJson(res, status, { ok: false, error: { code, message: message(error) } })
|
|
507
529
|
}
|
|
508
530
|
}
|
|
@@ -529,7 +551,20 @@ export async function handleUpload(
|
|
|
529
551
|
* request.
|
|
530
552
|
*/
|
|
531
553
|
export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
554
|
+
// CU-1: the plugin's own log ring (SPEC 方案 C) — rebuilt per apply, so a
|
|
555
|
+
// row reload clears it (expected: the report describes THIS process
|
|
556
|
+
// generation). `logWarn` / `logInfo` are the branch-shared double-writers:
|
|
557
|
+
// every line below reaches the context logger exactly as before and the
|
|
558
|
+
// ring besides, so the debug report (CU-2) can answer "what did the plugin
|
|
559
|
+
// actually log" without a log file (the desktop app writes none).
|
|
560
|
+
const ring = createLogRing()
|
|
561
|
+
const pluginLog = createPluginLog(ring, ctx.logger as Parameters<typeof createPluginLog>[1])
|
|
562
|
+
const logWarn = pluginLog.warn
|
|
563
|
+
const logInfo = pluginLog.info
|
|
532
564
|
const effective = resolveConfig(config, readFileConfig(), process.env)
|
|
565
|
+
// Lifecycle: the row came up. Role and both versions are the first things
|
|
566
|
+
// an issue report needs.
|
|
567
|
+
ring.push('info', 'row apply: role=%s plugin=%s dsh=%s', effective.values.role, pluginVersion, resolveDshVersion())
|
|
533
568
|
// RD-6 probe fact: whether the fetch-route inject callback (below, in the
|
|
534
569
|
// client-half wiring) ever fired. On a composition where `connection` or
|
|
535
570
|
// `fileUploads` never becomes available the callback stays silently
|
|
@@ -537,30 +572,49 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
537
572
|
// diagnostic stays undefined — this flag is the one honest signal of that
|
|
538
573
|
// state. Declared this early because probes on BOTH role branches read it.
|
|
539
574
|
let fetchRouteInjectSeen = false
|
|
575
|
+
/** BU-1: how the fetch-route wrap got installed — `'inject'` via the
|
|
576
|
+
* primary `ctx.inject` callback, `'fallback'` via the bounded lazy timer
|
|
577
|
+
* below, `undefined` while not installed. Read live by the service probe. */
|
|
578
|
+
let fetchRouteInstalledVia: 'inject' | 'fallback' | undefined
|
|
579
|
+
/** BU-1: ISO timestamp of the moment a fallback attempt FAILED (shape
|
|
580
|
+
* refused, original unresolvable) — the tick stops after one, so this is
|
|
581
|
+
* also "the fallback gave up here". An install success does not set it. */
|
|
582
|
+
let fetchRouteFallbackTriedAt: string | undefined
|
|
540
583
|
/** The client branch's fetch-route diagnostics getter, assigned once that
|
|
541
584
|
* branch declares it; the probe reads it lazily through this slot so both
|
|
542
585
|
* role mounts can share one probe factory. */
|
|
543
586
|
let fetchRouteDiagnosticsRef: (() => unknown) | undefined
|
|
544
587
|
/** RD-6: build the lazy service probe against a context whose own inject
|
|
545
588
|
* already resolved (the client-routes mounts qualify on both roles). One
|
|
546
|
-
* reflection per process
|
|
547
|
-
*
|
|
589
|
+
* reflection per process for the SERVICE PRESENCE facts (below); the
|
|
590
|
+
* fetch-route facts are re-read live on every call — they change while the
|
|
591
|
+
* page is open (the inject may fire, or the fallback may step in, long
|
|
592
|
+
* after the first probe). */
|
|
548
593
|
const uploadServiceProbe = (probeCtx: Context): (() => Record<string, unknown>) => {
|
|
549
|
-
let cached:
|
|
594
|
+
let cached: {
|
|
595
|
+
connectionService: string
|
|
596
|
+
fileUploadsService: string
|
|
597
|
+
connectionShape?: FetchRouteShapeCheck
|
|
598
|
+
} | undefined
|
|
550
599
|
return () => {
|
|
551
|
-
if (cached
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
600
|
+
if (cached === undefined) {
|
|
601
|
+
const reflect = (probeCtx as Context & { reflect?: { get(name: string): unknown } }).reflect
|
|
602
|
+
const connection = reflect?.get('connection')
|
|
603
|
+
const fileUploads = reflect?.get('fileUploads')
|
|
604
|
+
cached = {
|
|
605
|
+
connectionService: connection === undefined ? 'absent' : 'present',
|
|
606
|
+
fileUploadsService: fileUploads === undefined ? 'absent' : 'present',
|
|
607
|
+
...(connection === undefined ? {} : { connectionShape: checkFetchRouteShape(connection) }),
|
|
608
|
+
}
|
|
609
|
+
}
|
|
555
610
|
const fetchRoute = fetchRouteDiagnosticsRef?.()
|
|
556
|
-
|
|
611
|
+
return {
|
|
557
612
|
uploadInjectSeen: fetchRouteInjectSeen,
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
...
|
|
613
|
+
installedVia: fetchRouteInstalledVia,
|
|
614
|
+
...(fetchRouteFallbackTriedAt === undefined ? {} : { fallbackTriedAt: fetchRouteFallbackTriedAt }),
|
|
615
|
+
...cached,
|
|
561
616
|
...(fetchRoute === undefined ? {} : { fetchRoute }),
|
|
562
617
|
}
|
|
563
|
-
return cached
|
|
564
618
|
}
|
|
565
619
|
}
|
|
566
620
|
// The plugin bridge's zenRemote service (PB-1), provided on BOTH roles
|
|
@@ -580,7 +634,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
580
634
|
// failing the row over — the bridge stays off, everything else keeps
|
|
581
635
|
// working — so it degrades to a warning like the session feeds do.
|
|
582
636
|
const registry = createBridgeRegistry({
|
|
583
|
-
warn: (text) => {
|
|
637
|
+
warn: (text) => { logWarn('dsh-zen-remote plugin bridge: %s', text) },
|
|
584
638
|
})
|
|
585
639
|
bridgeRegistry = registry
|
|
586
640
|
try {
|
|
@@ -589,7 +643,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
589
643
|
}
|
|
590
644
|
ctx.provide('zenRemote', service)
|
|
591
645
|
} catch (error) {
|
|
592
|
-
|
|
646
|
+
logWarn('dsh-zen-remote: cannot provide the "zenRemote" service: %s', message(error))
|
|
593
647
|
}
|
|
594
648
|
ctx.effect(() => () => {
|
|
595
649
|
if (bridgeRegistry === registry) bridgeRegistry = undefined
|
|
@@ -613,7 +667,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
613
667
|
// halves.
|
|
614
668
|
const adapters: NormalizedAdapter[] = []
|
|
615
669
|
{
|
|
616
|
-
const adapterWarn = (text: string): void => {
|
|
670
|
+
const adapterWarn = (text: string): void => { logWarn('dsh-zen-remote adapter: %s', text) }
|
|
617
671
|
for (const adapter of ADAPTERS) {
|
|
618
672
|
try {
|
|
619
673
|
const holder = ctx as unknown as { get?: (name: string) => unknown; baseUrl?: string }
|
|
@@ -660,15 +714,27 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
660
714
|
registry.registerNormalized(normalized.declaration)
|
|
661
715
|
adapters.push(normalized)
|
|
662
716
|
if (unlisted && surfaceDigest !== undefined) {
|
|
663
|
-
|
|
717
|
+
logInfo('dsh-zen-remote adapter: %s bridged by surface digest %s (version %s unlisted)', normalized.plugin, surfaceDigest, installedVersion)
|
|
664
718
|
} else {
|
|
665
|
-
|
|
719
|
+
logInfo('dsh-zen-remote adapter: %s adapted at version %s', normalized.plugin, installedVersion)
|
|
666
720
|
}
|
|
667
721
|
} catch (error) {
|
|
668
722
|
adapterWarn(`${adapter.plugin} refused: ${error instanceof Error ? error.message : String(error)}`)
|
|
669
723
|
}
|
|
670
724
|
}
|
|
671
725
|
}
|
|
726
|
+
// CU-3: the debug-report route's profile fact — read per call through the
|
|
727
|
+
// reflection layer's store lookup (a composition without profileContext
|
|
728
|
+
// answers undefined, and a throwing read degrades to undefined too).
|
|
729
|
+
const profileName = (): string | undefined => {
|
|
730
|
+
try {
|
|
731
|
+
const holder = ctx as unknown as { get?: (name: string) => unknown }
|
|
732
|
+
const profile = holder.get?.('profileContext') as { name?: unknown } | undefined
|
|
733
|
+
return typeof profile?.name === 'string' && profile.name !== '' ? profile.name : undefined
|
|
734
|
+
} catch {
|
|
735
|
+
return undefined
|
|
736
|
+
}
|
|
737
|
+
}
|
|
672
738
|
if (effective.values.role === 'host') {
|
|
673
739
|
// A row that switched from client to host must not leave a stale relay
|
|
674
740
|
// client where getRelayClient() can find it (T23a-fix).
|
|
@@ -677,7 +743,24 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
677
743
|
// gateway child through LAN_GATE_RELAY_SECRET and kept here for the
|
|
678
744
|
// relay routes below. The push half never sees it.
|
|
679
745
|
const relaySecret = randomBytes(32).toString('hex')
|
|
680
|
-
|
|
746
|
+
// CU-1 lifecycle: the gateway child's spawn/exit, reported back through
|
|
747
|
+
// this hook (lan-gate.mjs reads it off the config object it is handed).
|
|
748
|
+
// The child runs in a subprocess whose console the ring cannot see, so
|
|
749
|
+
// these two lines are the debug report's only record of it. Either call
|
|
750
|
+
// must never break the gateway half — lan-gate.mjs guards its side, we
|
|
751
|
+
// guard ours. The hook rides as a NON-enumerable property: it is wiring,
|
|
752
|
+
// not configuration — the gateway's config stays exactly "resolved values
|
|
753
|
+
// + relaySecret" for every spread, deep compare and serialization (the
|
|
754
|
+
// role-wiring test pins that shape), while a direct read still finds it.
|
|
755
|
+
const gatewayEvent = (event: 'spawn' | 'exit', detail: Record<string, unknown>): void => {
|
|
756
|
+
try {
|
|
757
|
+
if (event === 'spawn') ring.push('info', 'gateway spawned (port=%s)', String(detail.port ?? '?'))
|
|
758
|
+
else ring.push('warn', 'gateway exited (code=%s signal=%s error=%s)', String(detail.code ?? '—'), String(detail.signal ?? '—'), String(detail.error ?? '—'))
|
|
759
|
+
} catch { /* the ring must never break the wiring */ }
|
|
760
|
+
}
|
|
761
|
+
const gatewayConfig: Record<string, unknown> = { ...effective.values, relaySecret }
|
|
762
|
+
Object.defineProperty(gatewayConfig, 'gatewayEvent', { value: gatewayEvent, enumerable: false })
|
|
763
|
+
ctx.plugin(gateway, gatewayConfig)
|
|
681
764
|
ctx.plugin(push, effective.values)
|
|
682
765
|
// The shared-session table backing the relay's access control. The relay
|
|
683
766
|
// route consumes it below; the activity tracker and idle sweeper (T22c)
|
|
@@ -720,7 +803,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
720
803
|
tracker.onEvent(sessionId, event.type)
|
|
721
804
|
})
|
|
722
805
|
} catch (error) {
|
|
723
|
-
|
|
806
|
+
logWarn('dsh-zen-remote cannot listen on "session/event": %s', message(error))
|
|
724
807
|
}
|
|
725
808
|
// Auto-share and fork-following (T33a), off `agent/created` — the only
|
|
726
809
|
// host event that fires EXACTLY ONCE per NEW session. Its `source` is
|
|
@@ -758,11 +841,11 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
758
841
|
parentOf: parentIndex.parentOf,
|
|
759
842
|
})
|
|
760
843
|
} catch (error) {
|
|
761
|
-
|
|
844
|
+
logWarn('dsh-zen-remote cannot share new session "%s": %s', agent.id, message(error))
|
|
762
845
|
}
|
|
763
846
|
})
|
|
764
847
|
} catch (error) {
|
|
765
|
-
|
|
848
|
+
logWarn('dsh-zen-remote cannot listen on "agent/created": %s', message(error))
|
|
766
849
|
}
|
|
767
850
|
// Busy restore (T33a): `busy` is never persisted, so right after a row
|
|
768
851
|
// restart every shared session believes itself idle — including one whose
|
|
@@ -785,19 +868,19 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
785
868
|
sessionPersistence?: { list(): Promise<readonly PersistedSessionSnapshot[]> } | undefined
|
|
786
869
|
}).sessionPersistence
|
|
787
870
|
if (persistence === undefined) {
|
|
788
|
-
|
|
871
|
+
logWarn('dsh-zen-remote: no sessionPersistence service — pre-restart subagent sessions stay not-shared until they emit an event')
|
|
789
872
|
return
|
|
790
873
|
}
|
|
791
874
|
void persistence
|
|
792
875
|
.list()
|
|
793
876
|
.then((snapshots) => {
|
|
794
877
|
const recorded = seedParentIndexFromSnapshots(parentIndex, snapshots)
|
|
795
|
-
if (recorded > 0)
|
|
878
|
+
if (recorded > 0) logInfo('dsh-zen-remote: restored %d persisted parent links', recorded)
|
|
796
879
|
})
|
|
797
|
-
.catch((error: unknown) =>
|
|
880
|
+
.catch((error: unknown) => logWarn('dsh-zen-remote cannot seed parent links from persisted sessions: %s', message(error)))
|
|
798
881
|
})
|
|
799
882
|
} catch (error) {
|
|
800
|
-
|
|
883
|
+
logWarn('dsh-zen-remote cannot listen on "sessionPersistence": %s', message(error))
|
|
801
884
|
}
|
|
802
885
|
// Settings-surface admin routes (T14). The browser cannot call the
|
|
803
886
|
// gateway's loopback-only admin API from DSH's origin, so the host
|
|
@@ -1221,6 +1304,35 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1221
1304
|
}
|
|
1222
1305
|
}, 'dsh-zen-remote: relay route')
|
|
1223
1306
|
})
|
|
1307
|
+
// CU-3 (host mount): the gateway child's summary plate for the debug
|
|
1308
|
+
// report — one best-effort probe of the gateway's own status endpoint
|
|
1309
|
+
// (the same seam the wizard's runtime probe reads) reduced to liveness,
|
|
1310
|
+
// port and COUNTS. The endpoint's device rows carry no tokens and we
|
|
1311
|
+
// keep none of their fields anyway; the share table's size is read off
|
|
1312
|
+
// the store this apply owns. The gateway being down IS the summary.
|
|
1313
|
+
const gatewaySummary = async (): Promise<Record<string, unknown>> => {
|
|
1314
|
+
let reachable = false
|
|
1315
|
+
let port: number | undefined
|
|
1316
|
+
let deviceCount: number | undefined
|
|
1317
|
+
let pushSubscriptions: number | undefined
|
|
1318
|
+
try {
|
|
1319
|
+
const response = await fetch(`http://127.0.0.1:${effective.values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) })
|
|
1320
|
+
reachable = response.ok
|
|
1321
|
+
if (reachable) {
|
|
1322
|
+
const body = await response.json() as { port?: unknown; devices?: unknown; pushSubscriptions?: unknown }
|
|
1323
|
+
if (typeof body?.port === 'number') port = body.port
|
|
1324
|
+
if (Array.isArray(body?.devices)) deviceCount = body.devices.length
|
|
1325
|
+
if (typeof body?.pushSubscriptions === 'number') pushSubscriptions = body.pushSubscriptions
|
|
1326
|
+
}
|
|
1327
|
+
} catch { /* unreachable is an honest answer */ }
|
|
1328
|
+
return {
|
|
1329
|
+
reachable,
|
|
1330
|
+
...(port === undefined ? {} : { port }),
|
|
1331
|
+
...(deviceCount === undefined ? {} : { deviceCount }),
|
|
1332
|
+
...(pushSubscriptions === undefined ? {} : { pushSubscriptions }),
|
|
1333
|
+
shareCount: store.list().length,
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1224
1336
|
// The remote-status route lives on BOTH roles (T34-fix): it is what the
|
|
1225
1337
|
// client-half parts poll, and a desktop app can switch roles without a
|
|
1226
1338
|
// code path change — on a host it reads the empty
|
|
@@ -1239,6 +1351,10 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1239
1351
|
getRowConfig: () => config,
|
|
1240
1352
|
remoteStatusOnly: true,
|
|
1241
1353
|
getServiceProbe: uploadServiceProbe(clientCtx),
|
|
1354
|
+
getLogSnapshot: () => ring.snapshot(),
|
|
1355
|
+
getGatewaySummary: gatewaySummary,
|
|
1356
|
+
getProfileName: profileName,
|
|
1357
|
+
getPluginVersion: () => pluginVersion,
|
|
1242
1358
|
}),
|
|
1243
1359
|
}), 'dsh-zen-remote: client routes (host)')
|
|
1244
1360
|
})
|
|
@@ -1280,6 +1396,22 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1280
1396
|
// fresh handshake or a link flip shows up at the very next read.
|
|
1281
1397
|
const pluginBridgeStatus = (): BridgeStatus[] =>
|
|
1282
1398
|
compareBridges(registry.list(), relayClient.handshakeInfo?.plugins, relayClient.state === 'online')
|
|
1399
|
+
// CU-1 lifecycle: the relay link's flips enter the ring — `online` with
|
|
1400
|
+
// the server id it handshook, `offline` with the mapped failure code
|
|
1401
|
+
// (relayClient.lastError is a short code by contract, never free text).
|
|
1402
|
+
// subscribe re-announces the CURRENT state when only the handshake
|
|
1403
|
+
// identity moved (the T59 heartbeat), so the dedupe below keeps the
|
|
1404
|
+
// heartbeats out of the ring: one line per actual state change.
|
|
1405
|
+
let lastRelayRingState: string | undefined
|
|
1406
|
+
const offRelayRing = relayClient.subscribe((state) => {
|
|
1407
|
+
if (state === lastRelayRingState) return
|
|
1408
|
+
lastRelayRingState = state
|
|
1409
|
+
if (state === 'online') {
|
|
1410
|
+
ring.push('info', 'relay link online (server=%s)', relayClient.handshakeInfo?.serverId ?? '?')
|
|
1411
|
+
} else if (state === 'offline') {
|
|
1412
|
+
ring.push('warn', 'relay link offline (code=%s)', relayClient.lastError ?? '?')
|
|
1413
|
+
}
|
|
1414
|
+
})
|
|
1283
1415
|
// A stopped or reloaded row must not leave a stale instance where the
|
|
1284
1416
|
// module getter can hand it out (T23a-fix), and its reconnect machinery
|
|
1285
1417
|
// (T43) must not outlive the row either: stop() cancels the armed retry
|
|
@@ -1287,6 +1419,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1287
1419
|
// dispose from clearing a NEWER row's instance that a reload already
|
|
1288
1420
|
// installed.
|
|
1289
1421
|
ctx.effect(() => () => {
|
|
1422
|
+
offRelayRing()
|
|
1290
1423
|
relayClient.stop()
|
|
1291
1424
|
if (clientRelayClient === relayClient) clientRelayClient = undefined
|
|
1292
1425
|
}, 'dsh-zen-remote: relay client disposal')
|
|
@@ -1322,7 +1455,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1322
1455
|
off()
|
|
1323
1456
|
}
|
|
1324
1457
|
} catch (error) {
|
|
1325
|
-
|
|
1458
|
+
logWarn('dsh-zen-remote: cannot listen on "loader/volatile-update" for relay credentials: %s', message(error))
|
|
1326
1459
|
return () => {}
|
|
1327
1460
|
}
|
|
1328
1461
|
}, 'dsh-zen-remote: relay credentials watcher')
|
|
@@ -1333,7 +1466,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1333
1466
|
void relayClient.connect().catch((error) => {
|
|
1334
1467
|
if (error instanceof RelayError && error.code === 'unpaired') return
|
|
1335
1468
|
const code = error instanceof RelayError ? error.code : 'error'
|
|
1336
|
-
|
|
1469
|
+
logWarn('dsh-zen-remote relay client initial connect failed (%s): %s', code, message(error))
|
|
1337
1470
|
})
|
|
1338
1471
|
// The typertGateway interception (T23b-1): remote-session calls ride the
|
|
1339
1472
|
// relay, everything else reaches the local gateway untouched. The live
|
|
@@ -1397,9 +1530,6 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1397
1530
|
return undefined
|
|
1398
1531
|
}
|
|
1399
1532
|
ctx.inject(['typertGateway'], (gatewayCtx) => {
|
|
1400
|
-
// Degraded logging: the real cordis context always carries a logger,
|
|
1401
|
-
// but a row fake without one must not crash apply.
|
|
1402
|
-
const logWarn = (...args: Parameters<Context['logger']['warn']>): void => { ctx.logger?.warn(...args) }
|
|
1403
1533
|
const proxy = (gatewayCtx as Context & { typertGateway?: unknown }).typertGateway
|
|
1404
1534
|
const raw = proxy !== null && typeof proxy === 'object'
|
|
1405
1535
|
? (proxy as Record<PropertyKey, unknown>)[symbols.original]
|
|
@@ -1425,7 +1555,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1425
1555
|
// PB-25b: each merged stream reports its real-session inventory
|
|
1426
1556
|
// share into the union above.
|
|
1427
1557
|
createSessionInventorySlot,
|
|
1428
|
-
log: (format, ...args) => {
|
|
1558
|
+
log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args) },
|
|
1429
1559
|
})
|
|
1430
1560
|
interceptHandle = handle
|
|
1431
1561
|
gatewayCtx.effect(() => () => {
|
|
@@ -1447,7 +1577,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1447
1577
|
void runSelfCheck(raw, {
|
|
1448
1578
|
handle,
|
|
1449
1579
|
log: logWarn,
|
|
1450
|
-
info: (format, ...args) => {
|
|
1580
|
+
info: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args) },
|
|
1451
1581
|
getPeer: createOwnerPeerSource(() => gatewayCtx.reflect.get('connection')),
|
|
1452
1582
|
})
|
|
1453
1583
|
})
|
|
@@ -1490,8 +1620,12 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1490
1620
|
fetchRouteDiagnosticsRef = fetchRouteDiagnostics
|
|
1491
1621
|
ctx.inject(['connection', 'fileUploads'], (connectionCtx) => {
|
|
1492
1622
|
fetchRouteInjectSeen = true
|
|
1493
|
-
|
|
1494
|
-
|
|
1623
|
+
// BU-1 double-install guard: the fallback timer may have installed
|
|
1624
|
+
// while this callback sat PENDING — or recorded a refusal. Either way
|
|
1625
|
+
// this primary path has nothing left to do; installing again would
|
|
1626
|
+
// chain a second wrap over the first (behaviorally right, structurally
|
|
1627
|
+
// dirty) and overwrite the diagnostics' installedVia.
|
|
1628
|
+
if (fetchRouteHandle !== undefined || fetchRouteRefused !== undefined) return
|
|
1495
1629
|
const proxy = (connectionCtx as Context & { connection?: unknown }).connection
|
|
1496
1630
|
const raw = proxy !== null && typeof proxy === 'object'
|
|
1497
1631
|
? (proxy as unknown as Record<PropertyKey, unknown>)[symbols.original]
|
|
@@ -1517,11 +1651,109 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1517
1651
|
log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args) },
|
|
1518
1652
|
})
|
|
1519
1653
|
fetchRouteHandle = handle
|
|
1654
|
+
fetchRouteInstalledVia = 'inject'
|
|
1655
|
+
// CU-1 lifecycle: a successful install logs nothing today (the wrap's
|
|
1656
|
+
// own log lines only fire per call), so the ring gets one explicit
|
|
1657
|
+
// record — the debug report's "did the redirect actually attach" fact.
|
|
1658
|
+
// The refused and fallback paths funnel through logWarn/logInfo below
|
|
1659
|
+
// and enter the ring that way.
|
|
1660
|
+
ring.push('info', 'fetch-route intercept attached (via=inject)')
|
|
1520
1661
|
connectionCtx.effect(() => () => {
|
|
1521
1662
|
handle.uninstall()
|
|
1522
|
-
if (fetchRouteHandle === handle)
|
|
1663
|
+
if (fetchRouteHandle === handle) {
|
|
1664
|
+
fetchRouteHandle = undefined
|
|
1665
|
+
fetchRouteInstalledVia = undefined
|
|
1666
|
+
}
|
|
1523
1667
|
}, 'dsh-zen-remote: fetch-route intercept')
|
|
1524
1668
|
})
|
|
1669
|
+
// BU-1: the fetch-route FALLBACK install (SPEC §2). RD-6 proved the
|
|
1670
|
+
// machine-local failure mode: on one Windows client the
|
|
1671
|
+
// `['connection','fileUploads']` inject above sat silently PENDING (a
|
|
1672
|
+
// stuck row keeps cordis waiting forever, printing nothing), the wrap
|
|
1673
|
+
// never installed, and every remote upload fell through to the local
|
|
1674
|
+
// backend. The inject stays the FIRST choice — this timer only acts when
|
|
1675
|
+
// it demonstrably has not fired while BOTH services are present in the
|
|
1676
|
+
// reflection layer, then installs through the same
|
|
1677
|
+
// checkFetchRouteShape → symbols.original → installFetchRouteIntercept
|
|
1678
|
+
// path, sharing the handle/refused slots (which is also the
|
|
1679
|
+
// double-install guard: whichever path runs first, the other sees the
|
|
1680
|
+
// slots set and stands down). Bounded: a 3 s tick, at most 20 (a minute)
|
|
1681
|
+
// — a composition that never ships the services stops probing; unref'd
|
|
1682
|
+
// and cleared with the row.
|
|
1683
|
+
let fallbackTimer: ReturnType<typeof setInterval> | undefined
|
|
1684
|
+
let fallbackAttempts = 0
|
|
1685
|
+
const stopFallbackTimer = (): void => {
|
|
1686
|
+
if (fallbackTimer !== undefined) {
|
|
1687
|
+
clearInterval(fallbackTimer)
|
|
1688
|
+
fallbackTimer = undefined
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
fallbackTimer = setInterval(() => {
|
|
1692
|
+
// The tick budget is checked BEFORE acting but does not cancel the
|
|
1693
|
+
// current tick: attempt #20 still runs, no tick #21 ever comes.
|
|
1694
|
+
fallbackAttempts += 1
|
|
1695
|
+
if (fallbackAttempts >= 20) stopFallbackTimer()
|
|
1696
|
+
// Same reflection the service probe reads: both services must be
|
|
1697
|
+
// PRESENT — their absence means a legitimate startup wait (or a
|
|
1698
|
+
// composition without them), where the inject must stay first choice.
|
|
1699
|
+
const reflect = (ctx as Context & { reflect?: { get(name: string): unknown } }).reflect
|
|
1700
|
+
const connection = reflect?.get('connection')
|
|
1701
|
+
const fileUploads = reflect?.get('fileUploads')
|
|
1702
|
+
if (!shouldFallbackInstall({
|
|
1703
|
+
injectSeen: fetchRouteInjectSeen,
|
|
1704
|
+
handleSet: fetchRouteHandle !== undefined,
|
|
1705
|
+
refused: fetchRouteRefused !== undefined,
|
|
1706
|
+
servicesPresent: connection !== undefined && fileUploads !== undefined,
|
|
1707
|
+
})) {
|
|
1708
|
+
// The services being absent is a WAIT (next tick re-judges); the
|
|
1709
|
+
// primary path having advanced is a VERDICT — the fallback has no
|
|
1710
|
+
// business left, stop probing.
|
|
1711
|
+
if (fetchRouteInjectSeen || fetchRouteHandle !== undefined || fetchRouteRefused !== undefined) stopFallbackTimer()
|
|
1712
|
+
return
|
|
1713
|
+
}
|
|
1714
|
+
// Shape against the proxy first (fetchRoutes reads through it); the
|
|
1715
|
+
// raw instance comes off the same symbols slot the inject path uses.
|
|
1716
|
+
const shape = checkFetchRouteShape(connection)
|
|
1717
|
+
if (!shape.ok) {
|
|
1718
|
+
// Structural mismatch is a verdict, not a wait: record the attempt
|
|
1719
|
+
// and stop. A later inject trigger may still install (it checks the
|
|
1720
|
+
// RAW instance, which can differ from what the proxy exposed).
|
|
1721
|
+
fetchRouteFallbackTriedAt = new Date().toISOString()
|
|
1722
|
+
stopFallbackTimer()
|
|
1723
|
+
logWarn('dsh-zen-remote fetch-route fallback declined: %s', shape.reasons.join('; '))
|
|
1724
|
+
return
|
|
1725
|
+
}
|
|
1726
|
+
const raw = connection !== null && typeof connection === 'object'
|
|
1727
|
+
? (connection as unknown as Record<PropertyKey, unknown>)[symbols.original]
|
|
1728
|
+
: undefined
|
|
1729
|
+
if (raw === null || typeof raw !== 'object') {
|
|
1730
|
+
fetchRouteFallbackTriedAt = new Date().toISOString()
|
|
1731
|
+
stopFallbackTimer()
|
|
1732
|
+
logWarn('dsh-zen-remote fetch-route fallback declined: %s', 'original: connection[symbols.original] is not an object')
|
|
1733
|
+
return
|
|
1734
|
+
}
|
|
1735
|
+
const handle = installFetchRouteIntercept({
|
|
1736
|
+
connection: raw,
|
|
1737
|
+
relay: relayClient,
|
|
1738
|
+
getServerId: () => relayClient.handshakeInfo?.serverId,
|
|
1739
|
+
log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args) },
|
|
1740
|
+
})
|
|
1741
|
+
fetchRouteHandle = handle
|
|
1742
|
+
fetchRouteInstalledVia = 'fallback'
|
|
1743
|
+
stopFallbackTimer()
|
|
1744
|
+
logInfo('dsh-zen-remote fetch-route intercept installed via the fallback timer (inject never fired)')
|
|
1745
|
+
// Same disposal contract as the inject path: the wrap dies with the
|
|
1746
|
+
// row (the fallback has no service context of its own to hang on).
|
|
1747
|
+
ctx.effect(() => () => {
|
|
1748
|
+
handle.uninstall()
|
|
1749
|
+
if (fetchRouteHandle === handle) {
|
|
1750
|
+
fetchRouteHandle = undefined
|
|
1751
|
+
fetchRouteInstalledVia = undefined
|
|
1752
|
+
}
|
|
1753
|
+
}, 'dsh-zen-remote: fetch-route intercept (fallback)')
|
|
1754
|
+
}, 3_000)
|
|
1755
|
+
if (typeof fallbackTimer.unref === 'function') fallbackTimer.unref()
|
|
1756
|
+
ctx.effect(() => () => { stopFallbackTimer() }, 'dsh-zen-remote: fetch-route fallback timer')
|
|
1525
1757
|
// The plugin bridge's route interception (PB-4): the local webServer's
|
|
1526
1758
|
// `match` wrap forwarding bridged-plugin requests for REMOTE sessions
|
|
1527
1759
|
// through the relay. Injected on `webServer` alone — the only service
|
|
@@ -1552,7 +1784,6 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1552
1784
|
return undefined
|
|
1553
1785
|
}
|
|
1554
1786
|
ctx.inject(['webServer'], (wsCtx) => {
|
|
1555
|
-
const logWarn = (...args: Parameters<Context['logger']['warn']>): void => { ctx.logger?.warn(...args) }
|
|
1556
1787
|
const proxy = (wsCtx as Context & { webServer?: unknown }).webServer
|
|
1557
1788
|
const raw = proxy !== null && typeof proxy === 'object'
|
|
1558
1789
|
? (proxy as unknown as Record<PropertyKey, unknown>)[symbols.original]
|
|
@@ -1591,7 +1822,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1591
1822
|
// client's only early notice of a row that rides no workspace/follow
|
|
1592
1823
|
// frame — hand it to the summary sync so the sidebar row appears.
|
|
1593
1824
|
onMintedVirtualIds: (ids) => { interceptHandle?.nudgeSummarySync(ids) },
|
|
1594
|
-
log: (format, ...args) => {
|
|
1825
|
+
log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args) },
|
|
1595
1826
|
})
|
|
1596
1827
|
pluginRouteHandle = handle
|
|
1597
1828
|
wsCtx.effect(() => () => {
|
|
@@ -1613,6 +1844,9 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1613
1844
|
getPluginRouteIntercept: pluginRouteDiagnostics,
|
|
1614
1845
|
getPluginBridgeStatus: pluginBridgeStatus,
|
|
1615
1846
|
onCurrentSession,
|
|
1847
|
+
getLogSnapshot: () => ring.snapshot(),
|
|
1848
|
+
getProfileName: profileName,
|
|
1849
|
+
getPluginVersion: () => pluginVersion,
|
|
1616
1850
|
}),
|
|
1617
1851
|
}), 'dsh-zen-remote: client routes')
|
|
1618
1852
|
})
|
|
@@ -1639,17 +1873,21 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1639
1873
|
// dispose-and-reload entry. A fake context without a fiber (the route
|
|
1640
1874
|
// tests) only ever reaches the warn.
|
|
1641
1875
|
const reload = (): void => {
|
|
1876
|
+
// CU-1 lifecycle: the row is about to reload — the ring dies with this
|
|
1877
|
+
// apply right after, so this line is the reload's only witness in the
|
|
1878
|
+
// NEXT generation's report (the fresh apply's row-apply line follows).
|
|
1879
|
+
ring.push('info', 'row reload: a restart-required setting changed, reloading the plugin row')
|
|
1642
1880
|
const fiber = (ctx as { fiber?: { restart?: () => Promise<unknown> } }).fiber
|
|
1643
1881
|
if (fiber?.restart === undefined) {
|
|
1644
|
-
|
|
1882
|
+
logWarn('dsh-zen-remote: a restart-required setting changed, but no fiber is available to reload the plugin row')
|
|
1645
1883
|
return
|
|
1646
1884
|
}
|
|
1647
1885
|
try {
|
|
1648
1886
|
Promise.resolve(fiber.restart()).catch((error) => {
|
|
1649
|
-
|
|
1887
|
+
logWarn('dsh-zen-remote: plugin row reload failed: %s', message(error))
|
|
1650
1888
|
})
|
|
1651
1889
|
} catch (error) {
|
|
1652
|
-
|
|
1890
|
+
logWarn('dsh-zen-remote: plugin row reload failed: %s', message(error))
|
|
1653
1891
|
}
|
|
1654
1892
|
}
|
|
1655
1893
|
ctx.effect(() => {
|
|
@@ -1662,7 +1900,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1662
1900
|
try {
|
|
1663
1901
|
ctx.on('loader/volatile-update', () => { watcher.checkNow() })
|
|
1664
1902
|
} catch (error) {
|
|
1665
|
-
|
|
1903
|
+
logWarn('dsh-zen-remote: cannot listen on "loader/volatile-update": %s', message(error))
|
|
1666
1904
|
}
|
|
1667
1905
|
return watcher.stop
|
|
1668
1906
|
}, 'dsh-zen-remote: restart watcher')
|
|
@@ -1694,6 +1932,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
1694
1932
|
req,
|
|
1695
1933
|
res,
|
|
1696
1934
|
(admitReq) => webCtx.connection.admit(admitReq),
|
|
1935
|
+
logWarn,
|
|
1697
1936
|
)
|
|
1698
1937
|
},
|
|
1699
1938
|
}), 'dsh-mobile-nav: upload route')
|