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/lib/index.js
CHANGED
|
@@ -34,7 +34,7 @@ import { createRelayClient, RelayError, volatileUpdateTouchesServerName } from '
|
|
|
34
34
|
import { computeFingerprints } from './fingerprint.js';
|
|
35
35
|
import { runSelfCheck, installIntercept } from './intercept.js';
|
|
36
36
|
import { checkGatewayShape } from './intercept-shape.js';
|
|
37
|
-
import { checkFetchRouteShape, installFetchRouteIntercept } from './fetch-route-intercept.js';
|
|
37
|
+
import { checkFetchRouteShape, installFetchRouteIntercept, shouldFallbackInstall } from './fetch-route-intercept.js';
|
|
38
38
|
import { checkWebServerShape, installPluginRouteIntercept } from './plugin-route-intercept.js';
|
|
39
39
|
import { normalizeAdapter, adapterOwnsRegistryEntry } from './plugin-adapter-core.js';
|
|
40
40
|
import { ADAPTERS } from './adapters/index.js';
|
|
@@ -56,6 +56,7 @@ import { detectForwardedSource, parseProxyList } from './wizard/source-detect.js
|
|
|
56
56
|
import { createVerificationManager } from './wizard/verify.js';
|
|
57
57
|
import { createPluginLoopback } from './plugin-loopback.js';
|
|
58
58
|
import { createPluginTunnel } from './plugin-tunnel.js';
|
|
59
|
+
import { createLogRing, createPluginLog } from './log-ring.js';
|
|
59
60
|
import { DEFAULT_MAX_UPLOAD_BYTES } from './upload-limits.js';
|
|
60
61
|
// The loader's config schema (settings form) and the role normalizer live in
|
|
61
62
|
// src/config.ts next to the resolution they describe; re-exported so the
|
|
@@ -72,6 +73,20 @@ import * as gateway from '../lan-gate.mjs';
|
|
|
72
73
|
import * as push from '../dsh-push.mjs';
|
|
73
74
|
/** Exact route the phone composer POSTs one file body to. */
|
|
74
75
|
export const UPLOAD_ROUTE = '/_dsh/mobile-nav/upload';
|
|
76
|
+
/**
|
|
77
|
+
* This package's own version, read once off the package root (`../package.json`
|
|
78
|
+
* resolves there both from src/ under the type-stripping scripts and from the
|
|
79
|
+
* built lib/ in production). The debug report's `_meta` plate carries it —
|
|
80
|
+
* issue triage starts at "which build is this" — and the row-apply log line
|
|
81
|
+
* stamps it. Unreadable file degrades to `'unknown'`, never throws.
|
|
82
|
+
*/
|
|
83
|
+
let pluginVersion = 'unknown';
|
|
84
|
+
try {
|
|
85
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
86
|
+
if (typeof pkg?.version === 'string' && pkg.version !== '')
|
|
87
|
+
pluginVersion = pkg.version;
|
|
88
|
+
}
|
|
89
|
+
catch { /* keep 'unknown' */ }
|
|
75
90
|
/**
|
|
76
91
|
* The one piece of cordis the typert interception needs at RUNTIME: the
|
|
77
92
|
* registered symbol under which every cordis proxy answers with its raw
|
|
@@ -357,8 +372,14 @@ async function writeUpload(req, directory, filename, maxBytes) {
|
|
|
357
372
|
* request must ask the connection service itself, its refusal relayed
|
|
358
373
|
* verbatim — same first wall as the admin and client routes. Optional only
|
|
359
374
|
* for the direct-drive check scripts; production always passes it.
|
|
375
|
+
* @param warn - where the rejection line goes. apply() passes its double-
|
|
376
|
+
* writer (context logger + the CU-1 log ring), so a rejected upload — the
|
|
377
|
+
* very path issue #49 is triaged on — shows up in the debug report. The
|
|
378
|
+
* ring is per-apply on purpose (a row reload clears it), which is why it
|
|
379
|
+
* arrives as a parameter instead of a module singleton. Defaults to the
|
|
380
|
+
* context logger for the direct-drive check scripts.
|
|
360
381
|
*/
|
|
361
|
-
export async function handleUpload(ctx, maxBytes, req, res, admit) {
|
|
382
|
+
export async function handleUpload(ctx, maxBytes, req, res, admit, warn) {
|
|
362
383
|
if (admit !== undefined) {
|
|
363
384
|
const admission = admit(req);
|
|
364
385
|
if ('rejection' in admission) {
|
|
@@ -397,7 +418,10 @@ export async function handleUpload(ctx, maxBytes, req, res, admit) {
|
|
|
397
418
|
catch (error) {
|
|
398
419
|
const status = error instanceof UploadError ? error.status : 400;
|
|
399
420
|
const code = error instanceof UploadError ? error.code : 'upload-rejected';
|
|
400
|
-
|
|
421
|
+
if (warn !== undefined)
|
|
422
|
+
warn('dsh-mobile-nav upload rejected: %s', message(error));
|
|
423
|
+
else
|
|
424
|
+
ctx.logger.warn('dsh-mobile-nav upload rejected: %s', message(error));
|
|
401
425
|
responseJson(res, status, { ok: false, error: { code, message: message(error) } });
|
|
402
426
|
}
|
|
403
427
|
}
|
|
@@ -423,7 +447,20 @@ export async function handleUpload(ctx, maxBytes, req, res, admit) {
|
|
|
423
447
|
* request.
|
|
424
448
|
*/
|
|
425
449
|
export function apply(ctx, config = {}) {
|
|
450
|
+
// CU-1: the plugin's own log ring (SPEC 方案 C) — rebuilt per apply, so a
|
|
451
|
+
// row reload clears it (expected: the report describes THIS process
|
|
452
|
+
// generation). `logWarn` / `logInfo` are the branch-shared double-writers:
|
|
453
|
+
// every line below reaches the context logger exactly as before and the
|
|
454
|
+
// ring besides, so the debug report (CU-2) can answer "what did the plugin
|
|
455
|
+
// actually log" without a log file (the desktop app writes none).
|
|
456
|
+
const ring = createLogRing();
|
|
457
|
+
const pluginLog = createPluginLog(ring, ctx.logger);
|
|
458
|
+
const logWarn = pluginLog.warn;
|
|
459
|
+
const logInfo = pluginLog.info;
|
|
426
460
|
const effective = resolveConfig(config, readFileConfig(), process.env);
|
|
461
|
+
// Lifecycle: the row came up. Role and both versions are the first things
|
|
462
|
+
// an issue report needs.
|
|
463
|
+
ring.push('info', 'row apply: role=%s plugin=%s dsh=%s', effective.values.role, pluginVersion, resolveDshVersion());
|
|
427
464
|
// RD-6 probe fact: whether the fetch-route inject callback (below, in the
|
|
428
465
|
// client-half wiring) ever fired. On a composition where `connection` or
|
|
429
466
|
// `fileUploads` never becomes available the callback stays silently
|
|
@@ -431,31 +468,45 @@ export function apply(ctx, config = {}) {
|
|
|
431
468
|
// diagnostic stays undefined — this flag is the one honest signal of that
|
|
432
469
|
// state. Declared this early because probes on BOTH role branches read it.
|
|
433
470
|
let fetchRouteInjectSeen = false;
|
|
471
|
+
/** BU-1: how the fetch-route wrap got installed — `'inject'` via the
|
|
472
|
+
* primary `ctx.inject` callback, `'fallback'` via the bounded lazy timer
|
|
473
|
+
* below, `undefined` while not installed. Read live by the service probe. */
|
|
474
|
+
let fetchRouteInstalledVia;
|
|
475
|
+
/** BU-1: ISO timestamp of the moment a fallback attempt FAILED (shape
|
|
476
|
+
* refused, original unresolvable) — the tick stops after one, so this is
|
|
477
|
+
* also "the fallback gave up here". An install success does not set it. */
|
|
478
|
+
let fetchRouteFallbackTriedAt;
|
|
434
479
|
/** The client branch's fetch-route diagnostics getter, assigned once that
|
|
435
480
|
* branch declares it; the probe reads it lazily through this slot so both
|
|
436
481
|
* role mounts can share one probe factory. */
|
|
437
482
|
let fetchRouteDiagnosticsRef;
|
|
438
483
|
/** RD-6: build the lazy service probe against a context whose own inject
|
|
439
484
|
* already resolved (the client-routes mounts qualify on both roles). One
|
|
440
|
-
* reflection per process
|
|
441
|
-
*
|
|
485
|
+
* reflection per process for the SERVICE PRESENCE facts (below); the
|
|
486
|
+
* fetch-route facts are re-read live on every call — they change while the
|
|
487
|
+
* page is open (the inject may fire, or the fallback may step in, long
|
|
488
|
+
* after the first probe). */
|
|
442
489
|
const uploadServiceProbe = (probeCtx) => {
|
|
443
490
|
let cached;
|
|
444
491
|
return () => {
|
|
445
|
-
if (cached
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
492
|
+
if (cached === undefined) {
|
|
493
|
+
const reflect = probeCtx.reflect;
|
|
494
|
+
const connection = reflect?.get('connection');
|
|
495
|
+
const fileUploads = reflect?.get('fileUploads');
|
|
496
|
+
cached = {
|
|
497
|
+
connectionService: connection === undefined ? 'absent' : 'present',
|
|
498
|
+
fileUploadsService: fileUploads === undefined ? 'absent' : 'present',
|
|
499
|
+
...(connection === undefined ? {} : { connectionShape: checkFetchRouteShape(connection) }),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
450
502
|
const fetchRoute = fetchRouteDiagnosticsRef?.();
|
|
451
|
-
|
|
503
|
+
return {
|
|
452
504
|
uploadInjectSeen: fetchRouteInjectSeen,
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
...
|
|
505
|
+
installedVia: fetchRouteInstalledVia,
|
|
506
|
+
...(fetchRouteFallbackTriedAt === undefined ? {} : { fallbackTriedAt: fetchRouteFallbackTriedAt }),
|
|
507
|
+
...cached,
|
|
456
508
|
...(fetchRoute === undefined ? {} : { fetchRoute }),
|
|
457
509
|
};
|
|
458
|
-
return cached;
|
|
459
510
|
};
|
|
460
511
|
};
|
|
461
512
|
// The plugin bridge's zenRemote service (PB-1), provided on BOTH roles
|
|
@@ -475,7 +526,7 @@ export function apply(ctx, config = {}) {
|
|
|
475
526
|
// failing the row over — the bridge stays off, everything else keeps
|
|
476
527
|
// working — so it degrades to a warning like the session feeds do.
|
|
477
528
|
const registry = createBridgeRegistry({
|
|
478
|
-
warn: (text) => {
|
|
529
|
+
warn: (text) => { logWarn('dsh-zen-remote plugin bridge: %s', text); },
|
|
479
530
|
});
|
|
480
531
|
bridgeRegistry = registry;
|
|
481
532
|
try {
|
|
@@ -485,7 +536,7 @@ export function apply(ctx, config = {}) {
|
|
|
485
536
|
ctx.provide('zenRemote', service);
|
|
486
537
|
}
|
|
487
538
|
catch (error) {
|
|
488
|
-
|
|
539
|
+
logWarn('dsh-zen-remote: cannot provide the "zenRemote" service: %s', message(error));
|
|
489
540
|
}
|
|
490
541
|
ctx.effect(() => () => {
|
|
491
542
|
if (bridgeRegistry === registry)
|
|
@@ -510,7 +561,7 @@ export function apply(ctx, config = {}) {
|
|
|
510
561
|
// halves.
|
|
511
562
|
const adapters = [];
|
|
512
563
|
{
|
|
513
|
-
const adapterWarn = (text) => {
|
|
564
|
+
const adapterWarn = (text) => { logWarn('dsh-zen-remote adapter: %s', text); };
|
|
514
565
|
for (const adapter of ADAPTERS) {
|
|
515
566
|
try {
|
|
516
567
|
const holder = ctx;
|
|
@@ -560,10 +611,10 @@ export function apply(ctx, config = {}) {
|
|
|
560
611
|
registry.registerNormalized(normalized.declaration);
|
|
561
612
|
adapters.push(normalized);
|
|
562
613
|
if (unlisted && surfaceDigest !== undefined) {
|
|
563
|
-
|
|
614
|
+
logInfo('dsh-zen-remote adapter: %s bridged by surface digest %s (version %s unlisted)', normalized.plugin, surfaceDigest, installedVersion);
|
|
564
615
|
}
|
|
565
616
|
else {
|
|
566
|
-
|
|
617
|
+
logInfo('dsh-zen-remote adapter: %s adapted at version %s', normalized.plugin, installedVersion);
|
|
567
618
|
}
|
|
568
619
|
}
|
|
569
620
|
catch (error) {
|
|
@@ -571,6 +622,19 @@ export function apply(ctx, config = {}) {
|
|
|
571
622
|
}
|
|
572
623
|
}
|
|
573
624
|
}
|
|
625
|
+
// CU-3: the debug-report route's profile fact — read per call through the
|
|
626
|
+
// reflection layer's store lookup (a composition without profileContext
|
|
627
|
+
// answers undefined, and a throwing read degrades to undefined too).
|
|
628
|
+
const profileName = () => {
|
|
629
|
+
try {
|
|
630
|
+
const holder = ctx;
|
|
631
|
+
const profile = holder.get?.('profileContext');
|
|
632
|
+
return typeof profile?.name === 'string' && profile.name !== '' ? profile.name : undefined;
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
637
|
+
};
|
|
574
638
|
if (effective.values.role === 'host') {
|
|
575
639
|
// A row that switched from client to host must not leave a stale relay
|
|
576
640
|
// client where getRelayClient() can find it (T23a-fix).
|
|
@@ -579,7 +643,27 @@ export function apply(ctx, config = {}) {
|
|
|
579
643
|
// gateway child through LAN_GATE_RELAY_SECRET and kept here for the
|
|
580
644
|
// relay routes below. The push half never sees it.
|
|
581
645
|
const relaySecret = randomBytes(32).toString('hex');
|
|
582
|
-
|
|
646
|
+
// CU-1 lifecycle: the gateway child's spawn/exit, reported back through
|
|
647
|
+
// this hook (lan-gate.mjs reads it off the config object it is handed).
|
|
648
|
+
// The child runs in a subprocess whose console the ring cannot see, so
|
|
649
|
+
// these two lines are the debug report's only record of it. Either call
|
|
650
|
+
// must never break the gateway half — lan-gate.mjs guards its side, we
|
|
651
|
+
// guard ours. The hook rides as a NON-enumerable property: it is wiring,
|
|
652
|
+
// not configuration — the gateway's config stays exactly "resolved values
|
|
653
|
+
// + relaySecret" for every spread, deep compare and serialization (the
|
|
654
|
+
// role-wiring test pins that shape), while a direct read still finds it.
|
|
655
|
+
const gatewayEvent = (event, detail) => {
|
|
656
|
+
try {
|
|
657
|
+
if (event === 'spawn')
|
|
658
|
+
ring.push('info', 'gateway spawned (port=%s)', String(detail.port ?? '?'));
|
|
659
|
+
else
|
|
660
|
+
ring.push('warn', 'gateway exited (code=%s signal=%s error=%s)', String(detail.code ?? '—'), String(detail.signal ?? '—'), String(detail.error ?? '—'));
|
|
661
|
+
}
|
|
662
|
+
catch { /* the ring must never break the wiring */ }
|
|
663
|
+
};
|
|
664
|
+
const gatewayConfig = { ...effective.values, relaySecret };
|
|
665
|
+
Object.defineProperty(gatewayConfig, 'gatewayEvent', { value: gatewayEvent, enumerable: false });
|
|
666
|
+
ctx.plugin(gateway, gatewayConfig);
|
|
583
667
|
ctx.plugin(push, effective.values);
|
|
584
668
|
// The shared-session table backing the relay's access control. The relay
|
|
585
669
|
// route consumes it below; the activity tracker and idle sweeper (T22c)
|
|
@@ -624,7 +708,7 @@ export function apply(ctx, config = {}) {
|
|
|
624
708
|
});
|
|
625
709
|
}
|
|
626
710
|
catch (error) {
|
|
627
|
-
|
|
711
|
+
logWarn('dsh-zen-remote cannot listen on "session/event": %s', message(error));
|
|
628
712
|
}
|
|
629
713
|
// Auto-share and fork-following (T33a), off `agent/created` — the only
|
|
630
714
|
// host event that fires EXACTLY ONCE per NEW session. Its `source` is
|
|
@@ -664,12 +748,12 @@ export function apply(ctx, config = {}) {
|
|
|
664
748
|
});
|
|
665
749
|
}
|
|
666
750
|
catch (error) {
|
|
667
|
-
|
|
751
|
+
logWarn('dsh-zen-remote cannot share new session "%s": %s', agent.id, message(error));
|
|
668
752
|
}
|
|
669
753
|
});
|
|
670
754
|
}
|
|
671
755
|
catch (error) {
|
|
672
|
-
|
|
756
|
+
logWarn('dsh-zen-remote cannot listen on "agent/created": %s', message(error));
|
|
673
757
|
}
|
|
674
758
|
// Busy restore (T33a): `busy` is never persisted, so right after a row
|
|
675
759
|
// restart every shared session believes itself idle — including one whose
|
|
@@ -690,7 +774,7 @@ export function apply(ctx, config = {}) {
|
|
|
690
774
|
ctx.inject(['sessionPersistence'], (persistenceCtx) => {
|
|
691
775
|
const persistence = persistenceCtx.sessionPersistence;
|
|
692
776
|
if (persistence === undefined) {
|
|
693
|
-
|
|
777
|
+
logWarn('dsh-zen-remote: no sessionPersistence service — pre-restart subagent sessions stay not-shared until they emit an event');
|
|
694
778
|
return;
|
|
695
779
|
}
|
|
696
780
|
void persistence
|
|
@@ -698,13 +782,13 @@ export function apply(ctx, config = {}) {
|
|
|
698
782
|
.then((snapshots) => {
|
|
699
783
|
const recorded = seedParentIndexFromSnapshots(parentIndex, snapshots);
|
|
700
784
|
if (recorded > 0)
|
|
701
|
-
|
|
785
|
+
logInfo('dsh-zen-remote: restored %d persisted parent links', recorded);
|
|
702
786
|
})
|
|
703
|
-
.catch((error) =>
|
|
787
|
+
.catch((error) => logWarn('dsh-zen-remote cannot seed parent links from persisted sessions: %s', message(error)));
|
|
704
788
|
});
|
|
705
789
|
}
|
|
706
790
|
catch (error) {
|
|
707
|
-
|
|
791
|
+
logWarn('dsh-zen-remote cannot listen on "sessionPersistence": %s', message(error));
|
|
708
792
|
}
|
|
709
793
|
// Settings-surface admin routes (T14). The browser cannot call the
|
|
710
794
|
// gateway's loopback-only admin API from DSH's origin, so the host
|
|
@@ -1132,6 +1216,39 @@ export function apply(ctx, config = {}) {
|
|
|
1132
1216
|
};
|
|
1133
1217
|
}, 'dsh-zen-remote: relay route');
|
|
1134
1218
|
});
|
|
1219
|
+
// CU-3 (host mount): the gateway child's summary plate for the debug
|
|
1220
|
+
// report — one best-effort probe of the gateway's own status endpoint
|
|
1221
|
+
// (the same seam the wizard's runtime probe reads) reduced to liveness,
|
|
1222
|
+
// port and COUNTS. The endpoint's device rows carry no tokens and we
|
|
1223
|
+
// keep none of their fields anyway; the share table's size is read off
|
|
1224
|
+
// the store this apply owns. The gateway being down IS the summary.
|
|
1225
|
+
const gatewaySummary = async () => {
|
|
1226
|
+
let reachable = false;
|
|
1227
|
+
let port;
|
|
1228
|
+
let deviceCount;
|
|
1229
|
+
let pushSubscriptions;
|
|
1230
|
+
try {
|
|
1231
|
+
const response = await fetch(`http://127.0.0.1:${effective.values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) });
|
|
1232
|
+
reachable = response.ok;
|
|
1233
|
+
if (reachable) {
|
|
1234
|
+
const body = await response.json();
|
|
1235
|
+
if (typeof body?.port === 'number')
|
|
1236
|
+
port = body.port;
|
|
1237
|
+
if (Array.isArray(body?.devices))
|
|
1238
|
+
deviceCount = body.devices.length;
|
|
1239
|
+
if (typeof body?.pushSubscriptions === 'number')
|
|
1240
|
+
pushSubscriptions = body.pushSubscriptions;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
catch { /* unreachable is an honest answer */ }
|
|
1244
|
+
return {
|
|
1245
|
+
reachable,
|
|
1246
|
+
...(port === undefined ? {} : { port }),
|
|
1247
|
+
...(deviceCount === undefined ? {} : { deviceCount }),
|
|
1248
|
+
...(pushSubscriptions === undefined ? {} : { pushSubscriptions }),
|
|
1249
|
+
shareCount: store.list().length,
|
|
1250
|
+
};
|
|
1251
|
+
};
|
|
1135
1252
|
// The remote-status route lives on BOTH roles (T34-fix): it is what the
|
|
1136
1253
|
// client-half parts poll, and a desktop app can switch roles without a
|
|
1137
1254
|
// code path change — on a host it reads the empty
|
|
@@ -1150,6 +1267,10 @@ export function apply(ctx, config = {}) {
|
|
|
1150
1267
|
getRowConfig: () => config,
|
|
1151
1268
|
remoteStatusOnly: true,
|
|
1152
1269
|
getServiceProbe: uploadServiceProbe(clientCtx),
|
|
1270
|
+
getLogSnapshot: () => ring.snapshot(),
|
|
1271
|
+
getGatewaySummary: gatewaySummary,
|
|
1272
|
+
getProfileName: profileName,
|
|
1273
|
+
getPluginVersion: () => pluginVersion,
|
|
1153
1274
|
}),
|
|
1154
1275
|
}), 'dsh-zen-remote: client routes (host)');
|
|
1155
1276
|
});
|
|
@@ -1191,6 +1312,24 @@ export function apply(ctx, config = {}) {
|
|
|
1191
1312
|
// and `online` is the client's CURRENT state — so a registry change, a
|
|
1192
1313
|
// fresh handshake or a link flip shows up at the very next read.
|
|
1193
1314
|
const pluginBridgeStatus = () => compareBridges(registry.list(), relayClient.handshakeInfo?.plugins, relayClient.state === 'online');
|
|
1315
|
+
// CU-1 lifecycle: the relay link's flips enter the ring — `online` with
|
|
1316
|
+
// the server id it handshook, `offline` with the mapped failure code
|
|
1317
|
+
// (relayClient.lastError is a short code by contract, never free text).
|
|
1318
|
+
// subscribe re-announces the CURRENT state when only the handshake
|
|
1319
|
+
// identity moved (the T59 heartbeat), so the dedupe below keeps the
|
|
1320
|
+
// heartbeats out of the ring: one line per actual state change.
|
|
1321
|
+
let lastRelayRingState;
|
|
1322
|
+
const offRelayRing = relayClient.subscribe((state) => {
|
|
1323
|
+
if (state === lastRelayRingState)
|
|
1324
|
+
return;
|
|
1325
|
+
lastRelayRingState = state;
|
|
1326
|
+
if (state === 'online') {
|
|
1327
|
+
ring.push('info', 'relay link online (server=%s)', relayClient.handshakeInfo?.serverId ?? '?');
|
|
1328
|
+
}
|
|
1329
|
+
else if (state === 'offline') {
|
|
1330
|
+
ring.push('warn', 'relay link offline (code=%s)', relayClient.lastError ?? '?');
|
|
1331
|
+
}
|
|
1332
|
+
});
|
|
1194
1333
|
// A stopped or reloaded row must not leave a stale instance where the
|
|
1195
1334
|
// module getter can hand it out (T23a-fix), and its reconnect machinery
|
|
1196
1335
|
// (T43) must not outlive the row either: stop() cancels the armed retry
|
|
@@ -1198,6 +1337,7 @@ export function apply(ctx, config = {}) {
|
|
|
1198
1337
|
// dispose from clearing a NEWER row's instance that a reload already
|
|
1199
1338
|
// installed.
|
|
1200
1339
|
ctx.effect(() => () => {
|
|
1340
|
+
offRelayRing();
|
|
1201
1341
|
relayClient.stop();
|
|
1202
1342
|
if (clientRelayClient === relayClient)
|
|
1203
1343
|
clientRelayClient = undefined;
|
|
@@ -1237,7 +1377,7 @@ export function apply(ctx, config = {}) {
|
|
|
1237
1377
|
};
|
|
1238
1378
|
}
|
|
1239
1379
|
catch (error) {
|
|
1240
|
-
|
|
1380
|
+
logWarn('dsh-zen-remote: cannot listen on "loader/volatile-update" for relay credentials: %s', message(error));
|
|
1241
1381
|
return () => { };
|
|
1242
1382
|
}
|
|
1243
1383
|
}, 'dsh-zen-remote: relay credentials watcher');
|
|
@@ -1249,7 +1389,7 @@ export function apply(ctx, config = {}) {
|
|
|
1249
1389
|
if (error instanceof RelayError && error.code === 'unpaired')
|
|
1250
1390
|
return;
|
|
1251
1391
|
const code = error instanceof RelayError ? error.code : 'error';
|
|
1252
|
-
|
|
1392
|
+
logWarn('dsh-zen-remote relay client initial connect failed (%s): %s', code, message(error));
|
|
1253
1393
|
});
|
|
1254
1394
|
// The typertGateway interception (T23b-1): remote-session calls ride the
|
|
1255
1395
|
// relay, everything else reaches the local gateway untouched. The live
|
|
@@ -1316,9 +1456,6 @@ export function apply(ctx, config = {}) {
|
|
|
1316
1456
|
return undefined;
|
|
1317
1457
|
};
|
|
1318
1458
|
ctx.inject(['typertGateway'], (gatewayCtx) => {
|
|
1319
|
-
// Degraded logging: the real cordis context always carries a logger,
|
|
1320
|
-
// but a row fake without one must not crash apply.
|
|
1321
|
-
const logWarn = (...args) => { ctx.logger?.warn(...args); };
|
|
1322
1459
|
const proxy = gatewayCtx.typertGateway;
|
|
1323
1460
|
const raw = proxy !== null && typeof proxy === 'object'
|
|
1324
1461
|
? proxy[symbols.original]
|
|
@@ -1344,7 +1481,7 @@ export function apply(ctx, config = {}) {
|
|
|
1344
1481
|
// PB-25b: each merged stream reports its real-session inventory
|
|
1345
1482
|
// share into the union above.
|
|
1346
1483
|
createSessionInventorySlot,
|
|
1347
|
-
log: (format, ...args) => {
|
|
1484
|
+
log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
|
|
1348
1485
|
});
|
|
1349
1486
|
interceptHandle = handle;
|
|
1350
1487
|
gatewayCtx.effect(() => () => {
|
|
@@ -1367,7 +1504,7 @@ export function apply(ctx, config = {}) {
|
|
|
1367
1504
|
void runSelfCheck(raw, {
|
|
1368
1505
|
handle,
|
|
1369
1506
|
log: logWarn,
|
|
1370
|
-
info: (format, ...args) => {
|
|
1507
|
+
info: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
|
|
1371
1508
|
getPeer: createOwnerPeerSource(() => gatewayCtx.reflect.get('connection')),
|
|
1372
1509
|
});
|
|
1373
1510
|
});
|
|
@@ -1411,8 +1548,13 @@ export function apply(ctx, config = {}) {
|
|
|
1411
1548
|
fetchRouteDiagnosticsRef = fetchRouteDiagnostics;
|
|
1412
1549
|
ctx.inject(['connection', 'fileUploads'], (connectionCtx) => {
|
|
1413
1550
|
fetchRouteInjectSeen = true;
|
|
1414
|
-
|
|
1415
|
-
|
|
1551
|
+
// BU-1 double-install guard: the fallback timer may have installed
|
|
1552
|
+
// while this callback sat PENDING — or recorded a refusal. Either way
|
|
1553
|
+
// this primary path has nothing left to do; installing again would
|
|
1554
|
+
// chain a second wrap over the first (behaviorally right, structurally
|
|
1555
|
+
// dirty) and overwrite the diagnostics' installedVia.
|
|
1556
|
+
if (fetchRouteHandle !== undefined || fetchRouteRefused !== undefined)
|
|
1557
|
+
return;
|
|
1416
1558
|
const proxy = connectionCtx.connection;
|
|
1417
1559
|
const raw = proxy !== null && typeof proxy === 'object'
|
|
1418
1560
|
? proxy[symbols.original]
|
|
@@ -1438,12 +1580,112 @@ export function apply(ctx, config = {}) {
|
|
|
1438
1580
|
log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
|
|
1439
1581
|
});
|
|
1440
1582
|
fetchRouteHandle = handle;
|
|
1583
|
+
fetchRouteInstalledVia = 'inject';
|
|
1584
|
+
// CU-1 lifecycle: a successful install logs nothing today (the wrap's
|
|
1585
|
+
// own log lines only fire per call), so the ring gets one explicit
|
|
1586
|
+
// record — the debug report's "did the redirect actually attach" fact.
|
|
1587
|
+
// The refused and fallback paths funnel through logWarn/logInfo below
|
|
1588
|
+
// and enter the ring that way.
|
|
1589
|
+
ring.push('info', 'fetch-route intercept attached (via=inject)');
|
|
1441
1590
|
connectionCtx.effect(() => () => {
|
|
1442
1591
|
handle.uninstall();
|
|
1443
|
-
if (fetchRouteHandle === handle)
|
|
1592
|
+
if (fetchRouteHandle === handle) {
|
|
1444
1593
|
fetchRouteHandle = undefined;
|
|
1594
|
+
fetchRouteInstalledVia = undefined;
|
|
1595
|
+
}
|
|
1445
1596
|
}, 'dsh-zen-remote: fetch-route intercept');
|
|
1446
1597
|
});
|
|
1598
|
+
// BU-1: the fetch-route FALLBACK install (SPEC §2). RD-6 proved the
|
|
1599
|
+
// machine-local failure mode: on one Windows client the
|
|
1600
|
+
// `['connection','fileUploads']` inject above sat silently PENDING (a
|
|
1601
|
+
// stuck row keeps cordis waiting forever, printing nothing), the wrap
|
|
1602
|
+
// never installed, and every remote upload fell through to the local
|
|
1603
|
+
// backend. The inject stays the FIRST choice — this timer only acts when
|
|
1604
|
+
// it demonstrably has not fired while BOTH services are present in the
|
|
1605
|
+
// reflection layer, then installs through the same
|
|
1606
|
+
// checkFetchRouteShape → symbols.original → installFetchRouteIntercept
|
|
1607
|
+
// path, sharing the handle/refused slots (which is also the
|
|
1608
|
+
// double-install guard: whichever path runs first, the other sees the
|
|
1609
|
+
// slots set and stands down). Bounded: a 3 s tick, at most 20 (a minute)
|
|
1610
|
+
// — a composition that never ships the services stops probing; unref'd
|
|
1611
|
+
// and cleared with the row.
|
|
1612
|
+
let fallbackTimer;
|
|
1613
|
+
let fallbackAttempts = 0;
|
|
1614
|
+
const stopFallbackTimer = () => {
|
|
1615
|
+
if (fallbackTimer !== undefined) {
|
|
1616
|
+
clearInterval(fallbackTimer);
|
|
1617
|
+
fallbackTimer = undefined;
|
|
1618
|
+
}
|
|
1619
|
+
};
|
|
1620
|
+
fallbackTimer = setInterval(() => {
|
|
1621
|
+
// The tick budget is checked BEFORE acting but does not cancel the
|
|
1622
|
+
// current tick: attempt #20 still runs, no tick #21 ever comes.
|
|
1623
|
+
fallbackAttempts += 1;
|
|
1624
|
+
if (fallbackAttempts >= 20)
|
|
1625
|
+
stopFallbackTimer();
|
|
1626
|
+
// Same reflection the service probe reads: both services must be
|
|
1627
|
+
// PRESENT — their absence means a legitimate startup wait (or a
|
|
1628
|
+
// composition without them), where the inject must stay first choice.
|
|
1629
|
+
const reflect = ctx.reflect;
|
|
1630
|
+
const connection = reflect?.get('connection');
|
|
1631
|
+
const fileUploads = reflect?.get('fileUploads');
|
|
1632
|
+
if (!shouldFallbackInstall({
|
|
1633
|
+
injectSeen: fetchRouteInjectSeen,
|
|
1634
|
+
handleSet: fetchRouteHandle !== undefined,
|
|
1635
|
+
refused: fetchRouteRefused !== undefined,
|
|
1636
|
+
servicesPresent: connection !== undefined && fileUploads !== undefined,
|
|
1637
|
+
})) {
|
|
1638
|
+
// The services being absent is a WAIT (next tick re-judges); the
|
|
1639
|
+
// primary path having advanced is a VERDICT — the fallback has no
|
|
1640
|
+
// business left, stop probing.
|
|
1641
|
+
if (fetchRouteInjectSeen || fetchRouteHandle !== undefined || fetchRouteRefused !== undefined)
|
|
1642
|
+
stopFallbackTimer();
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
// Shape against the proxy first (fetchRoutes reads through it); the
|
|
1646
|
+
// raw instance comes off the same symbols slot the inject path uses.
|
|
1647
|
+
const shape = checkFetchRouteShape(connection);
|
|
1648
|
+
if (!shape.ok) {
|
|
1649
|
+
// Structural mismatch is a verdict, not a wait: record the attempt
|
|
1650
|
+
// and stop. A later inject trigger may still install (it checks the
|
|
1651
|
+
// RAW instance, which can differ from what the proxy exposed).
|
|
1652
|
+
fetchRouteFallbackTriedAt = new Date().toISOString();
|
|
1653
|
+
stopFallbackTimer();
|
|
1654
|
+
logWarn('dsh-zen-remote fetch-route fallback declined: %s', shape.reasons.join('; '));
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
const raw = connection !== null && typeof connection === 'object'
|
|
1658
|
+
? connection[symbols.original]
|
|
1659
|
+
: undefined;
|
|
1660
|
+
if (raw === null || typeof raw !== 'object') {
|
|
1661
|
+
fetchRouteFallbackTriedAt = new Date().toISOString();
|
|
1662
|
+
stopFallbackTimer();
|
|
1663
|
+
logWarn('dsh-zen-remote fetch-route fallback declined: %s', 'original: connection[symbols.original] is not an object');
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
const handle = installFetchRouteIntercept({
|
|
1667
|
+
connection: raw,
|
|
1668
|
+
relay: relayClient,
|
|
1669
|
+
getServerId: () => relayClient.handshakeInfo?.serverId,
|
|
1670
|
+
log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
|
|
1671
|
+
});
|
|
1672
|
+
fetchRouteHandle = handle;
|
|
1673
|
+
fetchRouteInstalledVia = 'fallback';
|
|
1674
|
+
stopFallbackTimer();
|
|
1675
|
+
logInfo('dsh-zen-remote fetch-route intercept installed via the fallback timer (inject never fired)');
|
|
1676
|
+
// Same disposal contract as the inject path: the wrap dies with the
|
|
1677
|
+
// row (the fallback has no service context of its own to hang on).
|
|
1678
|
+
ctx.effect(() => () => {
|
|
1679
|
+
handle.uninstall();
|
|
1680
|
+
if (fetchRouteHandle === handle) {
|
|
1681
|
+
fetchRouteHandle = undefined;
|
|
1682
|
+
fetchRouteInstalledVia = undefined;
|
|
1683
|
+
}
|
|
1684
|
+
}, 'dsh-zen-remote: fetch-route intercept (fallback)');
|
|
1685
|
+
}, 3_000);
|
|
1686
|
+
if (typeof fallbackTimer.unref === 'function')
|
|
1687
|
+
fallbackTimer.unref();
|
|
1688
|
+
ctx.effect(() => () => { stopFallbackTimer(); }, 'dsh-zen-remote: fetch-route fallback timer');
|
|
1447
1689
|
// The plugin bridge's route interception (PB-4): the local webServer's
|
|
1448
1690
|
// `match` wrap forwarding bridged-plugin requests for REMOTE sessions
|
|
1449
1691
|
// through the relay. Injected on `webServer` alone — the only service
|
|
@@ -1475,7 +1717,6 @@ export function apply(ctx, config = {}) {
|
|
|
1475
1717
|
return undefined;
|
|
1476
1718
|
};
|
|
1477
1719
|
ctx.inject(['webServer'], (wsCtx) => {
|
|
1478
|
-
const logWarn = (...args) => { ctx.logger?.warn(...args); };
|
|
1479
1720
|
const proxy = wsCtx.webServer;
|
|
1480
1721
|
const raw = proxy !== null && typeof proxy === 'object'
|
|
1481
1722
|
? proxy[symbols.original]
|
|
@@ -1514,7 +1755,7 @@ export function apply(ctx, config = {}) {
|
|
|
1514
1755
|
// client's only early notice of a row that rides no workspace/follow
|
|
1515
1756
|
// frame — hand it to the summary sync so the sidebar row appears.
|
|
1516
1757
|
onMintedVirtualIds: (ids) => { interceptHandle?.nudgeSummarySync(ids); },
|
|
1517
|
-
log: (format, ...args) => {
|
|
1758
|
+
log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
|
|
1518
1759
|
});
|
|
1519
1760
|
pluginRouteHandle = handle;
|
|
1520
1761
|
wsCtx.effect(() => () => {
|
|
@@ -1537,6 +1778,9 @@ export function apply(ctx, config = {}) {
|
|
|
1537
1778
|
getPluginRouteIntercept: pluginRouteDiagnostics,
|
|
1538
1779
|
getPluginBridgeStatus: pluginBridgeStatus,
|
|
1539
1780
|
onCurrentSession,
|
|
1781
|
+
getLogSnapshot: () => ring.snapshot(),
|
|
1782
|
+
getProfileName: profileName,
|
|
1783
|
+
getPluginVersion: () => pluginVersion,
|
|
1540
1784
|
}),
|
|
1541
1785
|
}), 'dsh-zen-remote: client routes');
|
|
1542
1786
|
});
|
|
@@ -1563,18 +1807,22 @@ export function apply(ctx, config = {}) {
|
|
|
1563
1807
|
// dispose-and-reload entry. A fake context without a fiber (the route
|
|
1564
1808
|
// tests) only ever reaches the warn.
|
|
1565
1809
|
const reload = () => {
|
|
1810
|
+
// CU-1 lifecycle: the row is about to reload — the ring dies with this
|
|
1811
|
+
// apply right after, so this line is the reload's only witness in the
|
|
1812
|
+
// NEXT generation's report (the fresh apply's row-apply line follows).
|
|
1813
|
+
ring.push('info', 'row reload: a restart-required setting changed, reloading the plugin row');
|
|
1566
1814
|
const fiber = ctx.fiber;
|
|
1567
1815
|
if (fiber?.restart === undefined) {
|
|
1568
|
-
|
|
1816
|
+
logWarn('dsh-zen-remote: a restart-required setting changed, but no fiber is available to reload the plugin row');
|
|
1569
1817
|
return;
|
|
1570
1818
|
}
|
|
1571
1819
|
try {
|
|
1572
1820
|
Promise.resolve(fiber.restart()).catch((error) => {
|
|
1573
|
-
|
|
1821
|
+
logWarn('dsh-zen-remote: plugin row reload failed: %s', message(error));
|
|
1574
1822
|
});
|
|
1575
1823
|
}
|
|
1576
1824
|
catch (error) {
|
|
1577
|
-
|
|
1825
|
+
logWarn('dsh-zen-remote: plugin row reload failed: %s', message(error));
|
|
1578
1826
|
}
|
|
1579
1827
|
};
|
|
1580
1828
|
ctx.effect(() => {
|
|
@@ -1588,7 +1836,7 @@ export function apply(ctx, config = {}) {
|
|
|
1588
1836
|
ctx.on('loader/volatile-update', () => { watcher.checkNow(); });
|
|
1589
1837
|
}
|
|
1590
1838
|
catch (error) {
|
|
1591
|
-
|
|
1839
|
+
logWarn('dsh-zen-remote: cannot listen on "loader/volatile-update": %s', message(error));
|
|
1592
1840
|
}
|
|
1593
1841
|
return watcher.stop;
|
|
1594
1842
|
}, 'dsh-zen-remote: restart watcher');
|
|
@@ -1614,7 +1862,7 @@ export function apply(ctx, config = {}) {
|
|
|
1614
1862
|
// and a rejection escaping it must reach webServer's unified error
|
|
1615
1863
|
// handling instead of vanishing into an unhandled rejection (T17b
|
|
1616
1864
|
// restored the await-shaped contract the route used to have).
|
|
1617
|
-
return handleUpload(webCtx, maxBytes, req, res, (admitReq) => webCtx.connection.admit(admitReq));
|
|
1865
|
+
return handleUpload(webCtx, maxBytes, req, res, (admitReq) => webCtx.connection.admit(admitReq), logWarn);
|
|
1618
1866
|
},
|
|
1619
1867
|
}), 'dsh-mobile-nav: upload route');
|
|
1620
1868
|
});
|