dsh-zen-remote 2.2.7 → 2.2.9
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/interface.md +6 -4
- package/docs/remote-access.md +1 -0
- package/lib/client-routes.js +52 -0
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +506 -280
- package/lib/relay-client.js +71 -0
- package/lib/relay-client.js.map +1 -1
- package/lib/types/client/MobileAttachButton.d.ts +15 -7
- package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
- package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
- package/lib/types/client/effects/model-sheet-extras.d.ts +29 -25
- package/lib/types/client/effects/model-sheet-extras.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client-data/remote-status.d.ts +39 -2
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +19 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/relay-client.d.ts +10 -0
- package/lib/types/relay-client.d.ts.map +1 -1
- package/package.json +1 -1
- package/scripts/check-attach-upload.mjs +42 -0
- package/src/client/MobileAttachButton.tsx +23 -12
- package/src/client/MobileAttachChips.tsx +0 -7
- package/src/client/effects/model-sheet-extras.ts +233 -111
- package/src/client/styles/composer.css.ts +142 -83
- package/src/client-data/remote-status.ts +128 -2
- package/src/client-routes.ts +53 -0
- package/src/relay-client.ts +76 -0
- package/lib/types/client/host-attach.d.ts +0 -38
- package/lib/types/client/host-attach.d.ts.map +0 -1
- package/src/client/host-attach.ts +0 -54
|
@@ -6,11 +6,16 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Official structure this file reshapes (verified live at 390px, see the S3
|
|
8
8
|
// report): InputBar renders
|
|
9
|
-
// _card > _row > [_tools > (_add, _modes >
|
|
9
|
+
// _card > _row > [_tools > (_add, input[type=file] hidden, _modes >
|
|
10
|
+
// PermissionSelect, input.left)]
|
|
10
11
|
// [_trailing > (_standardControls > (input.right, input.model), _activity, _primary)]
|
|
11
12
|
// with `_tools` / `_trailing` / `_standardControls` as inner flex groups
|
|
12
13
|
// (0.1.7 added `_standardControls` and moved the ContextMeter out of the row
|
|
13
|
-
// into the dock under the card).
|
|
14
|
+
// into the dock under the card). That hidden file input is the stand-down
|
|
15
|
+
// probe host-attach.ts USED to key on — since #31 the phone's own attach
|
|
16
|
+
// button is a permanent resident instead, and DSH 0.1.5's second `_add`
|
|
17
|
+
// button (the standalone paperclip 0.2.x removed) is hidden by section 2.
|
|
18
|
+
// Flattening all three to
|
|
14
19
|
// `display: contents` turns every control into a direct flex item of `_row`,
|
|
15
20
|
// which is what makes a pure `order` reflow possible without touching a
|
|
16
21
|
// single official component.
|
|
@@ -23,8 +28,21 @@ const ROOT = '[data-slot="conversation.composer.bar"] > [class$="_root"]'
|
|
|
23
28
|
const RING_W = '30px'
|
|
24
29
|
/** DSH 0.1.7's wrapper around input.right + input.model inside `_trailing`. */
|
|
25
30
|
const STD = `${ROW} > [class$="_trailing"] > [class$="_standardControls"]`
|
|
26
|
-
/**
|
|
27
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The model seat's content, whichever plugin owns it: officially ModelSelect's
|
|
33
|
+
* `_root`, but a third party can replace the whole `conversation.input.model`
|
|
34
|
+
* slot (…codex-ui mounts its own selector there), and a subagent session has
|
|
35
|
+
* nothing matching `_root` at all. Spelled `*:not(…)` rather than bare `> *`
|
|
36
|
+
* on purpose: `:not([data-mobile-nav])` carries the same (0,1,0) the old
|
|
37
|
+
* `[class$="_root"]` did, keeping every rule below a full attribute CLASS
|
|
38
|
+
* ahead of the ≤1023px pill rules in layout.css.ts — their `[data-phase] …
|
|
39
|
+
* :has(…)` forms measure (0,6,1) on the trigger and (0,7,1) on the label
|
|
40
|
+
* with `!important`, so a bare `> *` would leave the phone winning by
|
|
41
|
+
* exactly one class on every MODEL-derived rule, one upstream attr away
|
|
42
|
+
* from losing. The :not() also keeps this plugin's own nodes from ever
|
|
43
|
+
* reading as the model seat, should one ever be mounted there.
|
|
44
|
+
*/
|
|
45
|
+
const MODEL = `${STD} > [data-slot="conversation.input.model"] > *:not([data-mobile-nav])`
|
|
28
46
|
/** The permission select's trigger button (structure only — the PermissionSelect hashed classes are never named). */
|
|
29
47
|
const PERM = `${ROW} > [class$="_tools"] > [class$="_modes"]`
|
|
30
48
|
/**
|
|
@@ -60,6 +78,12 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
60
78
|
[data-mobile-nav="attach-picker"] {
|
|
61
79
|
display: none !important;
|
|
62
80
|
}
|
|
81
|
+
/* The composer-chips strip (effects/model-sheet-extras.ts) — same
|
|
82
|
+
every-width default as the picker above: the phone block re-shows it
|
|
83
|
+
inside the dock rail; the desktop shell keeps none. */
|
|
84
|
+
[data-mobile-nav="composer-chips"] {
|
|
85
|
+
display: none !important;
|
|
86
|
+
}
|
|
63
87
|
|
|
64
88
|
@media (max-width: 767px) {
|
|
65
89
|
/* --- 1. flatten the two official groups ---
|
|
@@ -83,8 +107,12 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
83
107
|
}
|
|
84
108
|
|
|
85
109
|
/* --- 2. the running order ---
|
|
86
|
-
The attachment button (S3 placeholder, S7 wires it up) sits leftmost
|
|
87
|
-
|
|
110
|
+
The attachment button (S3 placeholder, S7 wires it up) sits leftmost and
|
|
111
|
+
is a PERMANENT resident now (#31): on DSH 0.2.x the standalone paperclip
|
|
112
|
+
moved into the "+" command menu while its hidden <input type="file">
|
|
113
|
+
stayed in _tools, so the old host-attach stand-down probe matched on
|
|
114
|
+
every host and the phone composer was left with no attach affordance at
|
|
115
|
+
all. The official "+" command menu keeps its seat right next to it, where
|
|
88
116
|
the two "insert something" affordances read as one cluster. */
|
|
89
117
|
${ROW} [data-mobile-nav="attach"] {
|
|
90
118
|
order: 1 !important;
|
|
@@ -92,6 +120,14 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
92
120
|
${ROW} > [class$="_tools"] > [class$="_add"] {
|
|
93
121
|
order: 2 !important;
|
|
94
122
|
}
|
|
123
|
+
/* DSH ≤ 0.1.5 still renders that second _add button (the standalone
|
|
124
|
+
paperclip) as a direct _tools child; hide it below 768px so the row
|
|
125
|
+
keeps exactly one attach control — ours, which uploads into the session
|
|
126
|
+
workspace the phone can actually reach. On 0.2.x the only button in
|
|
127
|
+
_tools is the first of its type, so this never matches. */
|
|
128
|
+
${ROW} > [class$="_tools"] > button[class$="_add"]:nth-of-type(2) {
|
|
129
|
+
display: none !important;
|
|
130
|
+
}
|
|
95
131
|
${ROW} > [class$="_tools"] > [class$="_modes"] {
|
|
96
132
|
order: 3 !important;
|
|
97
133
|
/* Rigid on purpose. This seat holds the permission chip, whose own
|
|
@@ -243,7 +279,8 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
243
279
|
caps the pill so the row's elastic gap survives extreme model ids;
|
|
244
280
|
the label rules below override the <=1023px grow rules in
|
|
245
281
|
layout.css.ts (flex 1 1 auto) that would otherwise pad the pill. */
|
|
246
|
-
${MODEL} > [class$="_trigger"]
|
|
282
|
+
${MODEL} > [class$="_trigger"],
|
|
283
|
+
${MODEL} > button {
|
|
247
284
|
background: var(--dsw-specific-selector, rgba(127, 127, 127, .12)) !important;
|
|
248
285
|
height: 30px !important;
|
|
249
286
|
width: auto !important;
|
|
@@ -269,6 +306,18 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
269
306
|
flex: none !important;
|
|
270
307
|
white-space: nowrap !important;
|
|
271
308
|
}
|
|
309
|
+
/* Third-party model selectors (…codex-ui) render a bare <button> with no
|
|
310
|
+
_trigger classes and text straight inside it; give that button the same
|
|
311
|
+
shrink+truncate contract the official label gets, so a long model id
|
|
312
|
+
ellipsizes instead of stretching the row. The official trigger also
|
|
313
|
+
matches this (harmless: it already ellipsizes its own label). */
|
|
314
|
+
${MODEL} > button {
|
|
315
|
+
max-width: 100% !important;
|
|
316
|
+
min-width: 0 !important;
|
|
317
|
+
overflow: hidden !important;
|
|
318
|
+
text-overflow: ellipsis !important;
|
|
319
|
+
white-space: nowrap !important;
|
|
320
|
+
}
|
|
272
321
|
/* PermissionSelect wraps its trigger in the Menu primitive's root span,
|
|
273
322
|
which must shrink to the icon button's fixed width. */
|
|
274
323
|
${PERM} > span:has(> button[class$="_trigger"]) {
|
|
@@ -336,90 +385,97 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
336
385
|
border-radius: 12px !important;
|
|
337
386
|
font-size: 15px !important;
|
|
338
387
|
}
|
|
339
|
-
/* --- 4a. third-party composer entries live in the
|
|
340
|
-
effects/model-sheet-extras.ts
|
|
341
|
-
|
|
342
|
-
|
|
388
|
+
/* --- 4a. third-party composer entries live in the dock's chip strip ---
|
|
389
|
+
effects/model-sheet-extras.ts collects the composer's secondary controls
|
|
390
|
+
(input.right, input.left, and anything a third party parks directly into
|
|
391
|
+
_tools / _standardControls / _trailing / _row) and moves the REAL nodes
|
|
392
|
+
into [data-mobile-nav="composer-chips"] — a 26px horizontally
|
|
393
|
+
scrollable strip the effect mounts at the head of the dock rail above
|
|
394
|
+
the card. Always visible, one tap, no sheet in between.
|
|
343
395
|
|
|
344
|
-
In the row: hidden, but ONLY once the effect has marked
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
is what lets the parked copy style itself freely — same element, two
|
|
355
|
-
homes, two rule sets. \`!important\` because the slot container carries an
|
|
356
|
-
inline \`display: contents\`. */
|
|
357
|
-
${STD} > [data-slot="conversation.input.right"][data-zen-sheet-extras] {
|
|
396
|
+
In the row: hidden, but ONLY once the effect has marked them. Two opt-in
|
|
397
|
+
markers, both written by the effect: the input.right CONTAINER gets
|
|
398
|
+
data-zen-sheet-extras (so an emptied wrapper leaves no flex gap), and
|
|
399
|
+
every collected node gets data-zen-composer-extra. Visibility comes back
|
|
400
|
+
the moment a node is parked (="open") or the effect releases it (marker
|
|
401
|
+
removed) — a version of the effect that declines to act, or never runs
|
|
402
|
+
at all, leaves every control exactly where the host put it; nothing can
|
|
403
|
+
end up hidden from both places at once (the 2026-09-06 review's rule). */
|
|
404
|
+
[data-slot="conversation.input.right"][data-zen-sheet-extras],
|
|
405
|
+
[data-zen-composer-extra]:not([data-zen-composer-extra="open"]) {
|
|
358
406
|
display: none !important;
|
|
359
407
|
}
|
|
360
|
-
/*
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
408
|
+
/* The strip itself: one 26px line at the rail's head (order 0, document
|
|
409
|
+
order first via the effect's prepend), scrolling sideways when the
|
|
410
|
+
collected controls overflow. Scoped to the dock CHILD — the generic
|
|
411
|
+
marker also sits on the emptied input.right container in the row, where
|
|
412
|
+
nothing but the opt-in hide above may touch it. This specificity (0,2,0)
|
|
413
|
+
is what beats the section 7 \`> *\` cage wherever they overlap (overflow,
|
|
414
|
+
max-width, radius are all !important there too). */
|
|
415
|
+
[data-slot="conversation.input.dock"] > [data-mobile-nav="composer-chips"] {
|
|
364
416
|
display: flex !important;
|
|
365
|
-
flex-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
the 48px the sheet's own cells use. \`> * > *\` reaches both shapes without
|
|
371
|
-
naming either plugin — subscriptions wraps its trigger in a positioning
|
|
372
|
-
div, vision-router puts its button straight in the slot. */
|
|
373
|
-
${MODEL_MENU} > [data-zen-sheet-extras] > *,
|
|
374
|
-
${MODEL_MENU} > [data-zen-sheet-extras] > * > button {
|
|
375
|
-
width: 100% !important;
|
|
376
|
-
max-width: none !important;
|
|
417
|
+
flex-flow: row nowrap !important;
|
|
418
|
+
align-items: center !important;
|
|
419
|
+
gap: 6px !important;
|
|
420
|
+
order: 0 !important;
|
|
421
|
+
flex: 0 1 auto !important;
|
|
377
422
|
min-width: 0 !important;
|
|
378
|
-
|
|
423
|
+
max-width: 100% !important;
|
|
424
|
+
min-height: 26px !important;
|
|
425
|
+
max-height: 26px !important;
|
|
426
|
+
border-radius: 0 !important;
|
|
427
|
+
overflow-x: auto !important;
|
|
428
|
+
overflow-y: hidden !important;
|
|
429
|
+
scrollbar-width: none !important;
|
|
379
430
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
min-height: 48px !important;
|
|
383
|
-
height: auto !important;
|
|
384
|
-
padding: 0 12px !important;
|
|
385
|
-
gap: 10px !important;
|
|
386
|
-
border-radius: 12px !important;
|
|
387
|
-
border: none !important;
|
|
388
|
-
background: transparent !important;
|
|
389
|
-
font-size: 15px !important;
|
|
390
|
-
/* Both plugins center their chip label, which is right for a pill in the
|
|
391
|
-
row and wrong for a full-width row: the sheet's own cells start their
|
|
392
|
-
label at the left edge. justify-content covers the flex triggers,
|
|
393
|
-
text-align the ones that are not flex containers. */
|
|
394
|
-
text-align: left !important;
|
|
395
|
-
justify-content: flex-start !important;
|
|
431
|
+
[data-slot="conversation.input.dock"] > [data-mobile-nav="composer-chips"]::-webkit-scrollbar {
|
|
432
|
+
display: none !important;
|
|
396
433
|
}
|
|
397
|
-
/*
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
instead of inheriting a decision that was about a different layout. */
|
|
403
|
-
${MODEL_MENU} > [data-zen-sheet-extras] > * {
|
|
434
|
+
/* Parked controls shrink from whatever the row allowed them to the rail's
|
|
435
|
+
26px pill scale: auto width again (no full-width rows here), tight
|
|
436
|
+
height, and the chip font the rest of the rail uses. */
|
|
437
|
+
[data-mobile-nav="composer-chips"] > * {
|
|
438
|
+
flex: 0 0 auto !important;
|
|
404
439
|
order: 0 !important;
|
|
440
|
+
display: inline-flex !important;
|
|
441
|
+
align-items: center !important;
|
|
442
|
+
min-height: 26px !important;
|
|
443
|
+
max-height: 26px !important;
|
|
444
|
+
width: auto !important;
|
|
445
|
+
max-width: none !important;
|
|
446
|
+
}
|
|
447
|
+
[data-mobile-nav="composer-chips"] button {
|
|
448
|
+
box-sizing: border-box !important;
|
|
449
|
+
height: 26px !important;
|
|
450
|
+
min-height: 26px !important;
|
|
451
|
+
max-height: 26px !important;
|
|
452
|
+
width: auto !important;
|
|
453
|
+
padding: 0 10px !important;
|
|
454
|
+
gap: 5px !important;
|
|
455
|
+
border-radius: 999px !important;
|
|
456
|
+
font-size: 11.5px !important;
|
|
457
|
+
line-height: 18px !important;
|
|
458
|
+
white-space: nowrap !important;
|
|
405
459
|
}
|
|
406
|
-
/* The vision toggle is squeezed to a 28px icon in the row
|
|
407
|
-
in the
|
|
408
|
-
|
|
460
|
+
/* The vision toggle is squeezed to a 28px icon-only button in the row
|
|
461
|
+
(compat.css.ts); in the strip it is a 14px icon + text pill, so its
|
|
462
|
+
label comes back. */
|
|
463
|
+
[data-mobile-nav="composer-chips"] [data-vision-router-mode-toggle] > span {
|
|
409
464
|
display: inline !important;
|
|
410
465
|
}
|
|
411
|
-
|
|
412
|
-
width:
|
|
413
|
-
height:
|
|
466
|
+
[data-mobile-nav="composer-chips"] [data-vision-router-mode-toggle] > svg:first-of-type {
|
|
467
|
+
width: 14px !important;
|
|
468
|
+
height: 14px !important;
|
|
414
469
|
}
|
|
415
470
|
/* The speed chip opens a menu of its own, anchored \`bottom: 100%\` to its
|
|
416
|
-
trigger. Inside the
|
|
417
|
-
|
|
418
|
-
uses for the host's two menus: position: fixed leaves the clip
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
471
|
+
trigger. Inside the strip — which is \`overflow-x: auto\`, so its content
|
|
472
|
+
clips at the strip's box — that menu would be cut off. Same escape
|
|
473
|
+
section 4 uses for the host's two menus: position: fixed leaves the clip
|
|
474
|
+
behind and it lands as its own bottom sheet over the composer. */
|
|
475
|
+
[class$="_composerSeat"]:has([data-zen-sheet-extras] [role="menu"]) {
|
|
476
|
+
z-index: 60 !important;
|
|
477
|
+
}
|
|
478
|
+
[data-zen-sheet-extras] [role="menu"] {
|
|
423
479
|
position: fixed !important;
|
|
424
480
|
left: 0 !important;
|
|
425
481
|
right: 0 !important;
|
|
@@ -434,13 +490,13 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
434
490
|
border-radius: 16px 16px 0 0 !important;
|
|
435
491
|
border-bottom: none !important;
|
|
436
492
|
padding: 8px 8px calc(8px + var(--mnav-sab)) !important;
|
|
437
|
-
/* Above the
|
|
438
|
-
|
|
439
|
-
|
|
493
|
+
/* Above the page content behind the seat (the seat is raised to 60 by
|
|
494
|
+
the rule above while a parked menu is open), below the session-info
|
|
495
|
+
sheet's 70 (info.css.ts). */
|
|
440
496
|
z-index: 61 !important;
|
|
441
497
|
box-shadow: 0 -8px 32px rgba(0, 0, 0, .18) !important;
|
|
442
498
|
}
|
|
443
|
-
|
|
499
|
+
[data-zen-sheet-extras] [role="menu"] [role="menuitemradio"] {
|
|
444
500
|
min-height: 48px !important;
|
|
445
501
|
border-radius: 12px !important;
|
|
446
502
|
font-size: 15px !important;
|
|
@@ -468,7 +524,10 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
|
|
|
468
524
|
body:has(${MODEL} > [class$="_menu"]) [data-chat-flow] + div,
|
|
469
525
|
body:has(> [id$="-menu"][class$="_menu"]:is([role="menu"], [role="group"])) [data-chat-flow] + div,
|
|
470
526
|
/* T70: the portaled permission menu (marked by effects/permission-sheet.ts). */
|
|
471
|
-
body:has(> div[role="menu"][data-zen-sheet="perm"]) [data-chat-flow] + div
|
|
527
|
+
body:has(> div[role="menu"][data-zen-sheet="perm"]) [data-chat-flow] + div,
|
|
528
|
+
/* T-issue30: an inline menu a parked chip opened (escaped to a fixed
|
|
529
|
+
sheet by 4a). */
|
|
530
|
+
body:has([data-zen-sheet-extras] [role="menu"]) [data-chat-flow] + div {
|
|
472
531
|
display: none !important;
|
|
473
532
|
}
|
|
474
533
|
/* --- 5. input box: two lines minimum, five lines maximum ---
|
|
@@ -35,6 +35,8 @@ import { zh } from '../client/locales.ts'
|
|
|
35
35
|
/** Same-origin client route feeding this store (host half: T34,
|
|
36
36
|
* src/client-routes.ts). */
|
|
37
37
|
export const REMOTE_STATUS_ROUTE = '/_dsh/zen-remote/client/remote-status'
|
|
38
|
+
/** the browser's network-restored hint (POST). */
|
|
39
|
+
export const NETWORK_HINT_ROUTE = '/_dsh/zen-remote/client/network-hint'
|
|
38
40
|
|
|
39
41
|
/** Same-origin client route closing one remote session from this machine. */
|
|
40
42
|
export const CLIENT_UNSHARE_ROUTE = '/_dsh/zen-remote/client/unshare'
|
|
@@ -224,6 +226,47 @@ export const documentVisibility: VisibilitySource = {
|
|
|
224
226
|
},
|
|
225
227
|
}
|
|
226
228
|
|
|
229
|
+
// --- the NETWORK-RESTORED hint source ---------------
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* the browser-side signal that a previously broken
|
|
233
|
+
* link MAY be back. Two triggers, both cheap and event-driven (never polled):
|
|
234
|
+
*
|
|
235
|
+
* - `window`'s `online` event — the browser's own reachability flag going
|
|
236
|
+
* true. Note this is a HINT, never proof: `navigator.onLine === true`
|
|
237
|
+
* means "an interface is up", not "the host is reachable". The host
|
|
238
|
+
* resolves the question with an authenticated `/ping` before anything
|
|
239
|
+
* reconnects.
|
|
240
|
+
* - `visibilitychange` → visible: a backgrounded tab (throttled timers, a
|
|
241
|
+
* possibly-changed network) coming back is the other moment a stale
|
|
242
|
+
* `offline` is most likely, and it costs one hint at most.
|
|
243
|
+
*
|
|
244
|
+
* Deliberately NOT wired: `window`'s `offline` event. Going offline needs no
|
|
245
|
+
* action (the poll and the streams already notice), and firing a hint there
|
|
246
|
+
* would only produce refused round-trips.
|
|
247
|
+
*/
|
|
248
|
+
export interface NetworkHintSource {
|
|
249
|
+
/** Attach; the returned function detaches. */
|
|
250
|
+
subscribe(listener: () => void): () => void
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The real source (Node / SSR: never notifying — a host has no navigator). */
|
|
254
|
+
export const windowNetworkHint: NetworkHintSource = {
|
|
255
|
+
subscribe: (listener) => {
|
|
256
|
+
if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return () => {}
|
|
257
|
+
const onOnline = (): void => { listener() }
|
|
258
|
+
const onVisibility = (): void => {
|
|
259
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'visible') listener()
|
|
260
|
+
}
|
|
261
|
+
window.addEventListener('online', onOnline)
|
|
262
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility)
|
|
263
|
+
return () => {
|
|
264
|
+
window.removeEventListener('online', onOnline)
|
|
265
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility)
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
|
|
227
270
|
/** How one `client/unshare` POST ended. A refused action carries the
|
|
228
271
|
* backend's error `code` when it sent one (`not-shared`, `remote-mismatch`,
|
|
229
272
|
* …). */
|
|
@@ -243,24 +286,42 @@ export interface RemoteStatusStore {
|
|
|
243
286
|
/** POST client/unshare for one VIRTUAL session id, then refresh.
|
|
244
287
|
* @returns the action's outcome. */
|
|
245
288
|
unshare(sessionId: string): Promise<RemoteUnshareOutcome>
|
|
289
|
+
|
|
290
|
+
/** fire one network-restored hint now (the same
|
|
291
|
+
* controlled round the window `online` event drives). Exposed for tests
|
|
292
|
+
* and for a host-side manual probe; never bypasses the host's probe. */
|
|
293
|
+
notifyNetworkRestored(): void
|
|
246
294
|
}
|
|
247
295
|
|
|
248
296
|
/** Poll cadence while the page is visible and at least one part is mounted
|
|
249
297
|
* (T34: 15 秒). */
|
|
250
298
|
export const REMOTE_STATUS_POLL_MS = 15_000
|
|
251
299
|
|
|
300
|
+
/** The minimum gap between two client-side network hints — the same 3s the
|
|
301
|
+
* host's `networkRestored()` enforces, so the two never fight. `hintGapMs`
|
|
302
|
+
* injects a smaller one (tests). */
|
|
303
|
+
const NETWORK_HINT_CLIENT_GAP_MS = 3_000
|
|
304
|
+
|
|
252
305
|
/**
|
|
253
306
|
* Build one store over an injectable fetch and visibility source — the
|
|
254
307
|
* shares-store discipline: latest-wins (an earlier request answering late
|
|
255
308
|
* never overwrites a newer table), every GET failure fails its own round,
|
|
256
309
|
* the timer is real while visible and gone while hidden, and becoming
|
|
257
310
|
* visible pulls once immediately. `pollMs` exists for tests: the shipped
|
|
258
|
-
* cadence is far too slow to observe.
|
|
311
|
+
* cadence is far too slow to observe. `hintGapMs` exists for the same
|
|
312
|
+
* reason: the shipped hint gap (default 3000) is far too slow to observe.
|
|
259
313
|
*/
|
|
260
314
|
export function createRemoteStatusStore(
|
|
261
315
|
fetchImpl: typeof fetch,
|
|
262
316
|
visibility: VisibilitySource = documentVisibility,
|
|
263
317
|
pollMs: number = REMOTE_STATUS_POLL_MS,
|
|
318
|
+
/** the network-restored hint source. Omitted → no
|
|
319
|
+
* hint wiring (a host mount, or a test). */
|
|
320
|
+
networkHint?: NetworkHintSource,
|
|
321
|
+
/** the minimum gap between two client-side hints, ms; default 3000 (the
|
|
322
|
+
* same anti-storm gap the host enforces). Exists for tests, like
|
|
323
|
+
* `pollMs`: the shipped gap is far too slow to observe. */
|
|
324
|
+
hintGapMs: number = NETWORK_HINT_CLIENT_GAP_MS,
|
|
264
325
|
): RemoteStatusStore {
|
|
265
326
|
const gate = createLatestGate()
|
|
266
327
|
const listeners = new Set<() => void>()
|
|
@@ -366,18 +427,71 @@ export function createRemoteStatusStore(
|
|
|
366
427
|
}
|
|
367
428
|
}
|
|
368
429
|
|
|
430
|
+
// ONE controlled round per hint.
|
|
431
|
+
//
|
|
432
|
+
// The chain is: hint → POST client/network-hint → host runs probeHealth()
|
|
433
|
+
// (authenticated GET /ping) → only a reachable host advances to
|
|
434
|
+
// networkRestored() (which is itself offline-only + 3s-gapped) → the next
|
|
435
|
+
// status poll reflects the new state, and the intercept layer's $zr/events
|
|
436
|
+
// leg reopens on its own once the relay reads online.
|
|
437
|
+
//
|
|
438
|
+
// THREE client-side guards keep a flapping machine from turning the hint
|
|
439
|
+
// into a poll: a healthy-link gate (the last status read said `online` —
|
|
440
|
+
// nothing to retire), an in-flight slot (one hint at a time), and a
|
|
441
|
+
// minimum gap (the same 3s the host enforces, so the two never fight).
|
|
442
|
+
let hintInFlight = false
|
|
443
|
+
let lastHintAt = 0
|
|
444
|
+
|
|
445
|
+
const sendNetworkHint = async (): Promise<void> => {
|
|
446
|
+
// The healthy-link gate: a hint exists to retire a stale `offline`. While
|
|
447
|
+
// the last status read says `online`, a tab switch or a browser `online`
|
|
448
|
+
// event must not spend the hint→ping→refresh triple. `view` undefined
|
|
449
|
+
// (no GET answered yet) passes — conservative before the first read.
|
|
450
|
+
if (snapshot.view?.state === 'online') return
|
|
451
|
+
if (hintInFlight) return
|
|
452
|
+
const now = Date.now()
|
|
453
|
+
if (now - lastHintAt < hintGapMs) return
|
|
454
|
+
lastHintAt = now
|
|
455
|
+
hintInFlight = true
|
|
456
|
+
try {
|
|
457
|
+
await fetchImpl(NETWORK_HINT_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
|
458
|
+
} catch {
|
|
459
|
+
// A hint that cannot be delivered is simply dropped — the poll and the
|
|
460
|
+
// streams are still the truth, and the next event tries again.
|
|
461
|
+
} finally {
|
|
462
|
+
hintInFlight = false
|
|
463
|
+
// Whatever the hint achieved, re-read the status so the UI stops
|
|
464
|
+
// showing a stale `offline` the moment the host is back.
|
|
465
|
+
void refresh()
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let offNetworkHint: (() => void) | undefined
|
|
470
|
+
const startHintWiring = (): void => {
|
|
471
|
+
if (networkHint === undefined || offNetworkHint !== undefined) return
|
|
472
|
+
offNetworkHint = networkHint.subscribe(() => { void sendNetworkHint() })
|
|
473
|
+
}
|
|
474
|
+
|
|
369
475
|
return {
|
|
370
476
|
getSnapshot: () => snapshot,
|
|
371
477
|
subscribe(listener: () => void): () => void {
|
|
372
478
|
listeners.add(listener)
|
|
373
479
|
syncPolling()
|
|
480
|
+
startHintWiring()
|
|
374
481
|
return () => {
|
|
375
482
|
listeners.delete(listener)
|
|
376
483
|
syncPolling()
|
|
484
|
+
// The hint listener follows the subscribers: no page, no hints.
|
|
485
|
+
if (listeners.size === 0 && offNetworkHint !== undefined) {
|
|
486
|
+
offNetworkHint()
|
|
487
|
+
offNetworkHint = undefined
|
|
488
|
+
}
|
|
377
489
|
}
|
|
378
490
|
},
|
|
379
491
|
refresh,
|
|
380
492
|
unshare,
|
|
493
|
+
/** explicit hint trigger (tests / manual probe). */
|
|
494
|
+
notifyNetworkRestored: () => { void sendNetworkHint() },
|
|
381
495
|
}
|
|
382
496
|
}
|
|
383
497
|
|
|
@@ -399,6 +513,18 @@ export function subscribeIfVirtual(store: RemoteStatusStore, sessionId: string,
|
|
|
399
513
|
* matter how many of the icon and banner parts are mounted at once. Created
|
|
400
514
|
* lazily over the real fetch, so check scripts never construct it. */
|
|
401
515
|
export function getRemoteStatusStore(): RemoteStatusStore {
|
|
402
|
-
|
|
516
|
+
// the REAL entry wiring. The network-hint source is
|
|
517
|
+
// passed here and nowhere else, so every mounted remote part (icon + banner)
|
|
518
|
+
// shares ONE store and therefore ONE hint subscription — no per-component
|
|
519
|
+
// listeners, no duplicate POSTs.
|
|
520
|
+
//
|
|
521
|
+
// Node / SSR: `windowNetworkHint.subscribe` returns a no-op detacher and
|
|
522
|
+
// never fires (there is no `window`), so a host mount is unaffected.
|
|
523
|
+
singleton ??= createRemoteStatusStore(
|
|
524
|
+
(input, init) => fetch(input, init),
|
|
525
|
+
documentVisibility,
|
|
526
|
+
REMOTE_STATUS_POLL_MS,
|
|
527
|
+
windowNetworkHint,
|
|
528
|
+
)
|
|
403
529
|
return singleton
|
|
404
530
|
}
|
package/src/client-routes.ts
CHANGED
|
@@ -52,6 +52,26 @@ export const CLIENT_STATUS_ROUTE = `${CLIENT_ROUTE_PREFIX}/status`
|
|
|
52
52
|
* answered 409 unless the relay client is currently `offline`. */
|
|
53
53
|
export const CLIENT_RECONNECT_ROUTE = `${CLIENT_ROUTE_PREFIX}/reconnect`
|
|
54
54
|
|
|
55
|
+
/** POST: the browser's NETWORK-RESTORED hint.
|
|
56
|
+
*
|
|
57
|
+
* Why this route exists: the host half runs in a plain Node process with no
|
|
58
|
+
* OS network events, and the browser half cannot touch the relay client's
|
|
59
|
+
* memory across the HTTP boundary. So the browser — the only place that can
|
|
60
|
+
* observe `online`/`visibilitychange` — reports the hint here, and the host
|
|
61
|
+
* turns it into ONE controlled probe + reconnect.
|
|
62
|
+
*
|
|
63
|
+
* Deliberately narrower than `reconnect`:
|
|
64
|
+
* - it first runs `probeHealth()` (authenticated GET /ping); a hint NEVER
|
|
65
|
+
* alone proves the link is back (`navigator.onLine === true` is not
|
|
66
|
+
* evidence);
|
|
67
|
+
* - only a successful probe advances to `networkRestored()`, which itself
|
|
68
|
+
* refuses outside `offline` and inside its 3s anti-storm gap;
|
|
69
|
+
* - it answers the probe outcome so the page can log/annotate it.
|
|
70
|
+
*
|
|
71
|
+
* It NEVER changes state by itself: `probeHealth` is read-only, and
|
|
72
|
+
* `networkRestored` is the same controlled path the settings button uses. */
|
|
73
|
+
export const CLIENT_NETWORK_HINT_ROUTE = `${CLIENT_ROUTE_PREFIX}/network-hint`
|
|
74
|
+
|
|
55
75
|
/** GET: the remote-session status the T34 client parts render from (T34) —
|
|
56
76
|
* the relay's serving state, the interface-compatibility verdict, the
|
|
57
77
|
* handshake's server name, and the closed-session map. The prefix is
|
|
@@ -589,6 +609,39 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
589
609
|
responseJson(res, 200, { ok: true })
|
|
590
610
|
return
|
|
591
611
|
}
|
|
612
|
+
if (route === CLIENT_NETWORK_HINT_ROUTE) {
|
|
613
|
+
if (method !== 'POST') {
|
|
614
|
+
res.setHeader('Allow', 'POST')
|
|
615
|
+
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use POST' } })
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
if (!sameOriginPost(req)) {
|
|
619
|
+
responseJson(res, 403, {
|
|
620
|
+
ok: false,
|
|
621
|
+
error: { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' },
|
|
622
|
+
})
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
const relay = options.getRelayClient?.()
|
|
626
|
+
if (relay === undefined) {
|
|
627
|
+
responseJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'No relay client is running' } })
|
|
628
|
+
return
|
|
629
|
+
}
|
|
630
|
+
// a hint is a QUESTION, not a verdict.
|
|
631
|
+
// `probeHealth` is one authenticated GET /ping: it proves the HOST is
|
|
632
|
+
// reachable with the current token — nothing about the long streams.
|
|
633
|
+
// Only a reachable host earns the controlled reconnect, and
|
|
634
|
+
// `networkRestored()` is what enforces "offline only" + the 3s gap.
|
|
635
|
+
const reachable = await relay.probeHealth()
|
|
636
|
+
if (!reachable) {
|
|
637
|
+
// Honest answer: the network may be back, the HOST is not.
|
|
638
|
+
responseJson(res, 200, { ok: true, reachable: false, reconnected: false })
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
const reconnected = relay.networkRestored()
|
|
642
|
+
responseJson(res, 200, { ok: true, reachable: true, reconnected })
|
|
643
|
+
return
|
|
644
|
+
}
|
|
592
645
|
if (route === CLIENT_REMOTE_STATUS_ROUTE) {
|
|
593
646
|
if (method !== 'GET') {
|
|
594
647
|
res.setHeader('Allow', 'GET')
|