dsh-zen-remote 2.2.5 → 2.2.6
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 -3
- package/README.md +10 -2
- package/README.zh-CN.md +10 -2
- package/docs/interface.md +17 -0
- package/docs/remote-access.en.md +9 -2
- package/docs/remote-access.md +9 -2
- package/lib/adapters/dsh-better-sidebar/index.js +1 -1
- package/lib/client-routes.js +3 -0
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +1164 -193
- package/lib/fetch-route-intercept.js +1 -1
- package/lib/index.js +73 -16
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +13 -0
- package/lib/intercept.js.map +1 -1
- package/lib/invoke-binary.js +8 -2
- package/lib/invoke-binary.js.map +1 -1
- package/lib/lan-gate-server.cjs +109 -2
- package/lib/owner-peer.js +23 -0
- package/lib/owner-peer.js.map +1 -1
- package/lib/plugin-adapter-core.js +2 -2
- package/lib/relay-access.js +24 -2
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-client.js +2 -2
- package/lib/relay-server.js +103 -100
- package/lib/relay-server.js.map +1 -1
- package/lib/types/client/MobileAttachButton.d.ts +5 -2
- package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
- package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/RemoteFileDownload.d.ts +57 -0
- package/lib/types/client/RemoteFileDownload.d.ts.map +1 -0
- package/lib/types/client/attach-upload.d.ts +32 -0
- package/lib/types/client/attach-upload.d.ts.map +1 -1
- package/lib/types/client/effects/remote-drop.d.ts +27 -0
- package/lib/types/client/effects/remote-drop.d.ts.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +11 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote-file-save.d.ts +112 -0
- package/lib/types/client/remote-file-save.d.ts.map +1 -0
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/compat.css.d.ts +1 -1
- package/lib/types/client/styles/compat.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/home.css.d.ts +1 -1
- package/lib/types/client/styles/home.css.d.ts.map +1 -1
- package/lib/types/client/styles/layout.css.d.ts +1 -1
- package/lib/types/client/styles/layout.css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client-data/remote-drop.d.ts +99 -0
- package/lib/types/client-data/remote-drop.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +7 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +6 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/invoke-binary.d.ts +4 -1
- package/lib/types/invoke-binary.d.ts.map +1 -1
- package/lib/types/owner-peer.d.ts +9 -0
- package/lib/types/owner-peer.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts +1 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts +10 -7
- package/lib/types/relay-server.d.ts.map +1 -1
- package/package.json +36 -36
- package/pwa/inject.js +20 -9
- package/pwa/sw.js +97 -21
- package/scripts/check-attach-upload.mjs +37 -0
- package/scripts/check-remote-drop.mjs +114 -0
- package/scripts/check-settings-form.mjs +3 -2
- package/scripts/check-upload-endpoint.mjs +14 -3
- package/src/adapters/dsh-better-sidebar/index.ts +1 -1
- package/src/client/MobileAttachButton.tsx +14 -9
- package/src/client/MobileAttachChips.tsx +4 -0
- package/src/client/MobileHome.tsx +79 -12
- package/src/client/RemoteFileDownload.tsx +173 -0
- package/src/client/attach-upload.ts +47 -0
- package/src/client/effects/remote-drop.ts +269 -0
- package/src/client/index.tsx +47 -0
- package/src/client/locales.ts +32 -0
- package/src/client/remote-file-save.ts +232 -0
- package/src/client/settings/SettingsSection.tsx +61 -1
- package/src/client/styles/compat.css.ts +43 -12
- package/src/client/styles/header.css.ts +63 -15
- package/src/client/styles/home.css.ts +39 -1
- package/src/client/styles/layout.css.ts +24 -7
- package/src/client/styles/remote-session.css.ts +38 -0
- package/src/client-data/remote-drop.ts +127 -0
- package/src/client-data/settings-form.ts +10 -0
- package/src/client-routes.ts +8 -0
- package/src/fetch-route-intercept.ts +1 -1
- package/src/index.ts +71 -18
- package/src/intercept.ts +13 -0
- package/src/invoke-binary.ts +8 -2
- package/src/owner-peer.ts +32 -0
- package/src/plugin-adapter-core.ts +2 -2
- package/src/relay-access.ts +24 -2
- package/src/relay-client.ts +2 -2
- package/src/relay-server.ts +114 -90
|
@@ -71,6 +71,7 @@ import type {
|
|
|
71
71
|
ZenRemoteSettingsForm,
|
|
72
72
|
} from '../../client-data/settings-form.ts'
|
|
73
73
|
import { NS } from '../locales.ts'
|
|
74
|
+
import { remoteDropDiagnostics } from '../effects/remote-drop.ts'
|
|
74
75
|
import { describeShare } from '../../client-data/shares.ts'
|
|
75
76
|
import type { ShareEntryView, SharesStore } from '../../client-data/shares.ts'
|
|
76
77
|
|
|
@@ -315,6 +316,11 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
|
|
|
315
316
|
// The client group's rendered view, hoisted for the countdown ticker and
|
|
316
317
|
// the retry-follow refresh below (T43).
|
|
317
318
|
const clientView = clientLoad.state === 'ready' || clientLoad.state === 'stale' ? clientLoad.view : undefined
|
|
319
|
+
// RD-3: the remote drop guard's browser-side counters, read at render
|
|
320
|
+
// time — no snapshot state of its own: the initial mount, the 15s
|
|
321
|
+
// connected-status poll and the countdown ticker all re-render this page,
|
|
322
|
+
// and each render takes the values as they are right then.
|
|
323
|
+
const dropDiag = remoteDropDiagnostics()
|
|
318
324
|
const clientRetryAt = clientView?.nextRetryAt
|
|
319
325
|
const clientCountingDown = clientRetryAt !== undefined
|
|
320
326
|
|
|
@@ -1158,7 +1164,7 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
|
|
|
1158
1164
|
earlier servers answer neither, and the section itself only
|
|
1159
1165
|
exists when there is at least one line to show. PB-5 adds the
|
|
1160
1166
|
bridge rows and the rerouting note on the same presence gate. */}
|
|
1161
|
-
{clientView !== undefined && (clientView.lastError !== '' || clientView.intercept !== undefined || clientView.compat !== undefined || (clientView.pluginBridge?.length ?? 0) > 0 || clientView.pluginRouteIntercept !== undefined) && (
|
|
1167
|
+
{clientView !== undefined && (clientView.lastError !== '' || clientView.intercept !== undefined || clientView.compat !== undefined || (clientView.pluginBridge?.length ?? 0) > 0 || clientView.pluginRouteIntercept !== undefined || dropDiag.installed || dropDiag.notInstalledReason !== undefined) && (
|
|
1162
1168
|
<>
|
|
1163
1169
|
<h3 className="zr-settings-card-title">{t('settings.client.diagTitle')}</h3>
|
|
1164
1170
|
{clientView.lastError !== '' && (
|
|
@@ -1195,6 +1201,60 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
|
|
|
1195
1201
|
)}
|
|
1196
1202
|
</>
|
|
1197
1203
|
)}
|
|
1204
|
+
{/* RD-3: the remote drop guard's own line — installed state
|
|
1205
|
+
plus the five counters. Browser-side values, so unlike the
|
|
1206
|
+
rows above it never rides the client/status body. */}
|
|
1207
|
+
{(dropDiag.installed || dropDiag.notInstalledReason !== undefined) && (
|
|
1208
|
+
<div className="zr-settings-field">
|
|
1209
|
+
<div className="zr-settings-head">
|
|
1210
|
+
<label>{t('settings.client.diagRemoteDrop')}</label>
|
|
1211
|
+
</div>
|
|
1212
|
+
<p className="zr-settings-hint">
|
|
1213
|
+
{dropDiag.installed
|
|
1214
|
+
? t('settings.client.diagRemoteDropOk', {
|
|
1215
|
+
intercepted: dropDiag.intercepted,
|
|
1216
|
+
redispatched: dropDiag.redispatched,
|
|
1217
|
+
blocked: dropDiag.blockedDirectories,
|
|
1218
|
+
passthrough: dropDiag.passthrough,
|
|
1219
|
+
failed: dropDiag.rewrapFailed,
|
|
1220
|
+
})
|
|
1221
|
+
: t('settings.client.diagRemoteDropOff', { reason: dropDiag.notInstalledReason ?? '—' })}
|
|
1222
|
+
</p>
|
|
1223
|
+
</div>
|
|
1224
|
+
)}
|
|
1225
|
+
{/* RD-6: the upload-redirect (fetch-route intercept) line — rides
|
|
1226
|
+
the client/status body's serviceProbe. One row answers the
|
|
1227
|
+
question this diagnosis was built for: whether the redirect
|
|
1228
|
+
is installed, refused (with the shape reason), or its service
|
|
1229
|
+
inject never fired — and which of the two services is
|
|
1230
|
+
missing when so. */}
|
|
1231
|
+
{clientView?.uploadProbe !== undefined && (() => {
|
|
1232
|
+
const probe = clientView.uploadProbe as {
|
|
1233
|
+
uploadInjectSeen?: unknown
|
|
1234
|
+
connectionService?: unknown
|
|
1235
|
+
fileUploadsService?: unknown
|
|
1236
|
+
fetchRoute?: { installed?: unknown; uploadWrapped?: unknown; shape?: { reasons?: unknown } }
|
|
1237
|
+
}
|
|
1238
|
+
const seen = probe.uploadInjectSeen === true
|
|
1239
|
+
const refused = seen && probe.fetchRoute?.shape?.reasons instanceof Array && (probe.fetchRoute.shape.reasons as string[]).length > 0
|
|
1240
|
+
const wrapped = probe.fetchRoute?.uploadWrapped === true
|
|
1241
|
+
const text = wrapped
|
|
1242
|
+
? t('settings.client.diagUploadRedirectOk')
|
|
1243
|
+
: refused
|
|
1244
|
+
? t('settings.client.diagUploadRedirectRefused', { reason: (probe.fetchRoute?.shape?.reasons as string[]).join('; ') })
|
|
1245
|
+
: t('settings.client.diagUploadRedirectPending', {
|
|
1246
|
+
connection: typeof probe.connectionService === 'string' ? probe.connectionService : '—',
|
|
1247
|
+
fileUploads: typeof probe.fileUploadsService === 'string' ? probe.fileUploadsService : '—',
|
|
1248
|
+
})
|
|
1249
|
+
return (
|
|
1250
|
+
<div className="zr-settings-field">
|
|
1251
|
+
<div className="zr-settings-head">
|
|
1252
|
+
<label>{t('settings.client.diagUploadRedirect')}</label>
|
|
1253
|
+
</div>
|
|
1254
|
+
<p className="zr-settings-hint">{text}</p>
|
|
1255
|
+
</div>
|
|
1256
|
+
)
|
|
1257
|
+
})()}
|
|
1198
1258
|
{clientView.compat !== undefined && (
|
|
1199
1259
|
<>
|
|
1200
1260
|
{clientView.compat.mismatchedGroups.length > 0 && (
|
|
@@ -299,31 +299,62 @@ export const COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility -----
|
|
|
299
299
|
one row with a thin scrollbar keeps every tab reachable and returns
|
|
300
300
|
that space to the options area (user feedback 2026-08-16). An earlier
|
|
301
301
|
one-row attempt had no scroll affordance and silently cut the last
|
|
302
|
-
tab off; the thin scrollbar IS the affordance.
|
|
303
|
-
|
|
304
|
-
|
|
302
|
+
tab off; the thin scrollbar IS the affordance.
|
|
303
|
+
|
|
304
|
+
2026-10-21 (DSH 0.2.1-rc.2, measured on a real phone shell): the whole
|
|
305
|
+
block below had silently stopped matching. The settings dialog no longer
|
|
306
|
+
renders inside the app frame — it is a PORTAL on body
|
|
307
|
+
(body > [class$="_overlay"] > [class$="_panel"]), so a
|
|
308
|
+
[data-mobile-nav="frame"] [aria-modal="true"] … prefix can never match
|
|
309
|
+
it, and the panel depth the old :has(> :first-child > :last-child >
|
|
310
|
+
button) gate was written against is no longer the shape the sheet has
|
|
311
|
+
(now panel > nav > (navTitle, navList, header)). Symptom: four nav cells
|
|
312
|
+
fell back to the host's wrapping grid (measured 209x132 for the list
|
|
313
|
+
instead of one 40px row) and the "open config file" action came back
|
|
314
|
+
into the header. Keep the same phone gate from the ancestor side
|
|
315
|
+
(html:has([data-mobile-nav="frame"]) — the marker only exists outside
|
|
316
|
+
the desktop gate) and identify the dialog by the nav list it owns, not
|
|
317
|
+
by a guessed depth. Desktop untouched.
|
|
318
|
+
|
|
319
|
+
This rule is the only place that declares the row's wrap/overflow now:
|
|
320
|
+
layout.css.ts's navList rule has a higher specificity and used to force
|
|
321
|
+
"flex-wrap: wrap; overflow: visible", so the one-row intent below never
|
|
322
|
+
actually rendered (see the note in layout.css.ts).
|
|
323
|
+
|
|
324
|
+
Every selector below is gated on :has([class$="_navList"]) — the one
|
|
325
|
+
stable feature of the settings sheet. The ancestor marker alone is not
|
|
326
|
+
enough: once the dialog is a body portal, [aria-modal="true"] matches
|
|
327
|
+
EVERY dialog on the phone (session export, directory picker, plugin
|
|
328
|
+
sheets), which is how the first cut of this block ended up hiding the
|
|
329
|
+
top-right action button of all of them (review, 2026-10-22). */
|
|
330
|
+
html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navList"] {
|
|
305
331
|
display: flex !important;
|
|
306
332
|
flex-wrap: nowrap !important;
|
|
333
|
+
/* The nav row is [navList][header(close)]: min-width:0 + flex-basis auto
|
|
334
|
+
lets the list shrink to the width left over by the ✕ and scroll inside
|
|
335
|
+
itself — without it the nowrap content widens the row and pushes the
|
|
336
|
+
close button off-screen. */
|
|
337
|
+
flex: 1 1 auto !important;
|
|
338
|
+
min-width: 0 !important;
|
|
307
339
|
overflow-x: auto !important;
|
|
308
340
|
overflow-y: hidden !important;
|
|
309
341
|
gap: 6px !important;
|
|
310
|
-
width: 100% !important;
|
|
311
342
|
scrollbar-width: thin !important;
|
|
312
343
|
-webkit-overflow-scrolling: touch !important;
|
|
313
344
|
}
|
|
314
345
|
/* Hairline scrollbar for the tab row: the default WebKit scrollbar reads
|
|
315
346
|
fat on a phone; 2px keeps the scroll affordance without the bulk. */
|
|
316
|
-
[data-mobile-nav="frame"] [aria-modal="true"] [class$="_navList"]::-webkit-scrollbar {
|
|
347
|
+
html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navList"]::-webkit-scrollbar {
|
|
317
348
|
height: 2px !important;
|
|
318
349
|
}
|
|
319
|
-
[data-mobile-nav="frame"] [aria-modal="true"] [class$="_navList"]::-webkit-scrollbar-thumb {
|
|
350
|
+
html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navList"]::-webkit-scrollbar-thumb {
|
|
320
351
|
background: var(--dsw-alias-border-l2, rgba(0, 0, 0, .22)) !important;
|
|
321
352
|
border-radius: 1px !important;
|
|
322
353
|
}
|
|
323
|
-
[data-mobile-nav="frame"] [aria-modal="true"] [class$="_navList"]::-webkit-scrollbar-track {
|
|
354
|
+
html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navList"]::-webkit-scrollbar-track {
|
|
324
355
|
background: transparent !important;
|
|
325
356
|
}
|
|
326
|
-
[data-mobile-nav="frame"] [aria-modal="true"] [class$="_navCell"] {
|
|
357
|
+
html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navCell"] {
|
|
327
358
|
flex: 0 0 auto !important;
|
|
328
359
|
white-space: nowrap !important;
|
|
329
360
|
padding: 6px 8px !important;
|
|
@@ -331,7 +362,7 @@ export const COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility -----
|
|
|
331
362
|
font-size: 13px !important;
|
|
332
363
|
justify-content: flex-start !important;
|
|
333
364
|
}
|
|
334
|
-
[data-mobile-nav="frame"] [aria-modal="true"] [class$="_navCell"] svg {
|
|
365
|
+
html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navCell"] svg {
|
|
335
366
|
width: 14px !important;
|
|
336
367
|
height: 14px !important;
|
|
337
368
|
flex: none !important;
|
|
@@ -339,11 +370,11 @@ export const COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility -----
|
|
|
339
370
|
/* Content toolbar: the "Open configuration file" button is hidden on
|
|
340
371
|
mobile — it is rarely needed on a phone and steals ~180px from the
|
|
341
372
|
tab row's scroll area (user feedback 2026-08-16). Only the close ✕
|
|
342
|
-
stays, flush right in the nav row. Desktop untouched (
|
|
343
|
-
[data-mobile-nav="frame"] [aria-modal="true"] [class$="_header"] [class$="_actions"] {
|
|
373
|
+
stays, flush right in the nav row. Desktop untouched (phone gate). */
|
|
374
|
+
html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_header"] [class$="_actions"] {
|
|
344
375
|
display: none !important;
|
|
345
376
|
}
|
|
346
|
-
[data-mobile-nav="frame"] [aria-modal="true"] [class$="_header"] [class$="_actions"] [class$="_action"] {
|
|
377
|
+
html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_header"] [class$="_actions"] [class$="_action"] {
|
|
347
378
|
font-size: 13px !important;
|
|
348
379
|
padding: 6px 12px !important;
|
|
349
380
|
min-height: 0 !important;
|
|
@@ -19,6 +19,13 @@
|
|
|
19
19
|
// hidden below, our workbench button routes to
|
|
20
20
|
// its controls instead)
|
|
21
21
|
// div.tabs[role="tablist"] Chat/Trajectory, only when there is >1 view
|
|
22
|
+
// (DSH <= 0.2.1-rc.2)
|
|
23
|
+
// div.tabs DSH 0.2.1-rc.3: the row lost its role and
|
|
24
|
+
// div.viewTabs[role="tablist"][data-conversation-tabs]
|
|
25
|
+
// div.viewsMoreAnchor "More conversation views" (…) menu launcher,
|
|
26
|
+
// present while conversation.session.header.
|
|
27
|
+
// views.actions has registrations (the web app
|
|
28
|
+
// ships dsh-client-ui-entry-customization)
|
|
22
29
|
|
|
23
30
|
export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) ---------- */
|
|
24
31
|
|
|
@@ -78,27 +85,39 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
|
|
|
78
85
|
above. */
|
|
79
86
|
z-index: 2;
|
|
80
87
|
padding: 0 8px 28px !important;
|
|
88
|
+
border-bottom: none !important;
|
|
81
89
|
}
|
|
82
90
|
/* No header bottom line — fade instead (real-device round 2, 2026-08-17).
|
|
83
|
-
The official header
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
91
|
+
The official header in rc.2 drew its border as a \`::after\` 1px bar; in
|
|
92
|
+
rc.3 (2026-10-25) the host replaced that pseudo-element with a plain 0.5px
|
|
93
|
+
border-bottom (cleared above) and added a blank 0px header in the hero
|
|
94
|
+
phase (\`_headerBlank\`). Declaring \`content: ''\` + \`position: absolute;
|
|
95
|
+
left: 0; right: 0\` explicitly on \`[data-phase="active"] header:not([class$="_headerBlank"])::after\`
|
|
96
|
+
keeps the 20px fade strip anchored right below the real session header on
|
|
97
|
+
both rc.2 and rc.3 without touching the scroll container:
|
|
98
|
+
- When the session header is display:none on the list page, ::after is
|
|
99
|
+
not generated at all (unlike _scrollBody::before, which stayed in flow
|
|
100
|
+
at y=0 whenever the header was hidden or blank and showed up at the top
|
|
101
|
+
of the window on iOS standalone PWA launch before data-view mutated).
|
|
102
|
+
- :not([class$="_headerBlank"]) excludes the 0-height blank header.
|
|
103
|
+
- color-mix instead of the transparent keyword: plain \`transparent\` is
|
|
104
|
+
rgba(0,0,0,0) — WebKit interpolates it through grey. */
|
|
105
|
+
[data-phase="active"] header:not([class$="_headerBlank"])::after {
|
|
106
|
+
content: '';
|
|
107
|
+
position: absolute;
|
|
108
|
+
left: 0;
|
|
109
|
+
right: 0;
|
|
98
110
|
top: 100% !important;
|
|
99
111
|
bottom: auto !important;
|
|
112
|
+
height: 20px !important;
|
|
113
|
+
pointer-events: none;
|
|
100
114
|
background: linear-gradient(to bottom, var(--dsw-alias-bg-base), color-mix(in srgb, var(--dsw-alias-bg-base) 0%, transparent)) !important;
|
|
101
115
|
}
|
|
116
|
+
[data-mobile-nav="frame"]:not([data-mobile-view="session"]) [data-phase] header::after,
|
|
117
|
+
body:has([data-mobile-nav="home"]:not([data-view="session"])) [data-phase] header::after {
|
|
118
|
+
content: none !important;
|
|
119
|
+
display: none !important;
|
|
120
|
+
}
|
|
102
121
|
/* grid-row: 1 on every item is load-bearing, not decoration (S2.1 fix for
|
|
103
122
|
the "标题被挤下去" report). The three items are placed with explicit
|
|
104
123
|
grid-column but arrive in DOM order crumbs(2) → headerActions(1) →
|
|
@@ -440,6 +459,35 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
|
|
|
440
459
|
[data-phase] header [class$="_tabs"][role="tablist"] {
|
|
441
460
|
display: none;
|
|
442
461
|
}
|
|
462
|
+
/* DSH 0.2.1-rc.3 split the row: div._tabs (no role) now wraps the
|
|
463
|
+
tablist (div._viewTabs[role=tablist][data-conversation-tabs]) and the
|
|
464
|
+
new "More conversation views" launcher, so the rule above matches
|
|
465
|
+
nothing there and the official row — plus a stray (…) More button —
|
|
466
|
+
came back under our view-switch band. It cannot simply be display:none
|
|
467
|
+
either: while the More menu has registrations the header folds every
|
|
468
|
+
View past floor((row.clientWidth - 48) / 120) into More, and a
|
|
469
|
+
display:none row measures 0, which would leave only Chat as a
|
|
470
|
+
[role=tab] for readViewTabs() (MobileSessionHeader.tsx) to mirror.
|
|
471
|
+
So the row keeps a wide layout box that paints nothing: absolute (out
|
|
472
|
+
of the header's flow, no band), 2048px wide (room for 16 tabs before
|
|
473
|
+
anything folds), scale(0) so it adds no scrollable overflow (clientWidth
|
|
474
|
+
ignores transforms), visibility:hidden + pointer-events:none so it is
|
|
475
|
+
never seen or hit. Its tabs still take the synthetic .click() the view
|
|
476
|
+
switcher sends — the same reasoning as the display:none rule above. */
|
|
477
|
+
[data-phase] header [class$="_tabs"]:has(> [data-conversation-tabs]) {
|
|
478
|
+
position: absolute !important;
|
|
479
|
+
top: 0;
|
|
480
|
+
left: 0;
|
|
481
|
+
width: 2048px !important;
|
|
482
|
+
max-width: none !important;
|
|
483
|
+
height: 0 !important;
|
|
484
|
+
margin: 0 !important;
|
|
485
|
+
overflow: hidden !important;
|
|
486
|
+
transform: scale(0);
|
|
487
|
+
transform-origin: 0 0;
|
|
488
|
+
visibility: hidden !important;
|
|
489
|
+
pointer-events: none !important;
|
|
490
|
+
}
|
|
443
491
|
|
|
444
492
|
/* View-switch row: "current view name + dots", replacing the (still
|
|
445
493
|
present, still clickable) official tablist visually. It renders inside
|
|
@@ -91,7 +91,17 @@ export const HOME_CSS = `/* ---------- phone app shell (< 768px) ---------- */
|
|
|
91
91
|
[data-mobile-nav="home"] {
|
|
92
92
|
position: absolute;
|
|
93
93
|
inset: 0;
|
|
94
|
-
|
|
94
|
+
/* rc.3 raise (real-device report 2026-10-25 "top strip leaking the
|
|
95
|
+
conversation through"): this shell layer mounts INSIDE the host's
|
|
96
|
+
PIRFFq_overlayLayer (z-index:20, new in rc.3 — the conversation
|
|
97
|
+
content itself moved under it too), so the old z:5 let host session
|
|
98
|
+
content (STRONG/CODE rows measured at viewport y=4) paint over the
|
|
99
|
+
shell's top bar on BOTH pages, reading as a faint fixed strip at the
|
|
100
|
+
window top. z:30 puts the whole shell above everything the host
|
|
101
|
+
stacks inside that layer; the shell's own transparent middle and
|
|
102
|
+
pointer-events passthrough keep the conversation visible and tappable
|
|
103
|
+
(verified live via console injection before landing). */
|
|
104
|
+
z-index: 30;
|
|
95
105
|
display: flex;
|
|
96
106
|
flex-direction: column;
|
|
97
107
|
pointer-events: auto;
|
|
@@ -136,6 +146,34 @@ export const HOME_CSS = `/* ---------- phone app shell (< 768px) ---------- */
|
|
|
136
146
|
opacity .3s var(--ds-ease-in-out, ease-in-out),
|
|
137
147
|
visibility 0s .3s;
|
|
138
148
|
}
|
|
149
|
+
/* The host's SESSION header must not sit on top of this list page.
|
|
150
|
+
Real-device report (2026-10-22, DSH 0.2.0-rc.2, dsh-web-mobile 3.2.1,
|
|
151
|
+
360x792 phone): on the list page the top strip showed the LAST SESSION's
|
|
152
|
+
header — its title plus the session's tab row — and the workspace switcher
|
|
153
|
+
underneath was neither visible nor tappable, so "switch workspace" did
|
|
154
|
+
nothing at all. Measured with elementFromPoint(150,30) on the phone
|
|
155
|
+
viewport: span.wSkVaW_crumbCurrent, i.e. the host header's crumb, while
|
|
156
|
+
our own workspace button sat at [46,8,208,44] underneath it.
|
|
157
|
+
|
|
158
|
+
Why z-index is not the fix: this page is position:absolute; z-index:5
|
|
159
|
+
inside the shell overlay layer, and the host header carries its own
|
|
160
|
+
stacking context, so raising us above its z-index:30 changed nothing
|
|
161
|
+
(measured: the crumb still won the hit test). The session header has to
|
|
162
|
+
leave the hit tree while the list page is up — the list page draws its
|
|
163
|
+
own top bar (home-top, with the workspace switcher).
|
|
164
|
+
|
|
165
|
+
The gate is this page's own marker: data-view="session" means a session
|
|
166
|
+
is open, so the rule lapses exactly when the host header is wanted again
|
|
167
|
+
(verified: entering a session restores display:grid, the back button in
|
|
168
|
+
that header stays tappable, and going back hides it once more). Two
|
|
169
|
+
selectors on purpose — the class suffix survives CSS-module hash renames,
|
|
170
|
+
the structural one survives a rename of the class itself. */
|
|
171
|
+
[data-mobile-nav="frame"]:not([data-mobile-view="session"]) header[class*="_header"],
|
|
172
|
+
[data-mobile-nav="frame"]:not([data-mobile-view="session"]) header:has([class*="_titleRow"]),
|
|
173
|
+
body:has([data-mobile-nav="home"]:not([data-view="session"])) [data-mobile-nav="frame"] header[class*="_header"],
|
|
174
|
+
body:has([data-mobile-nav="home"]:not([data-view="session"])) [data-mobile-nav="frame"] header:has([class*="_titleRow"]) {
|
|
175
|
+
display: none !important;
|
|
176
|
+
}
|
|
139
177
|
@media (prefers-reduced-motion: reduce) {
|
|
140
178
|
[data-mobile-nav="home"],
|
|
141
179
|
[data-mobile-nav="home"][data-view="session"] {
|
|
@@ -394,7 +394,14 @@ export const LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
|
|
|
394
394
|
display:none rule below, and the user can no longer type a path
|
|
395
395
|
(issue #12, 2026-08-16). The picker family keeps the official layout
|
|
396
396
|
on mobile in every mode. */
|
|
397
|
-
|
|
397
|
+
/* DSH 0.2.1-rc.2 renders this dialog as a body PORTAL
|
|
398
|
+
(body > overlay > panel), so the placement below must key off the nav
|
|
399
|
+
list the panel owns, not off a guessed child depth: that is both the
|
|
400
|
+
stable "this is the settings sheet" anchor and the reason the safe-area
|
|
401
|
+
offset below still applies inside the portal (the constants live on
|
|
402
|
+
:root, so they cross the portal boundary). The bottom inset is
|
|
403
|
+
subtracted too — a tall panel must not run under the home indicator. */
|
|
404
|
+
[aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])):not(:has([class*="ZuhsRW"])):has([class$="_navList"]) {
|
|
398
405
|
position: absolute !important;
|
|
399
406
|
left: 8px !important;
|
|
400
407
|
/* Fixed top (no translateY): a transform on the panel combined with the
|
|
@@ -405,11 +412,11 @@ export const LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
|
|
|
405
412
|
width: calc(100vw - 16px) !important;
|
|
406
413
|
max-width: calc(100vw - 16px) !important;
|
|
407
414
|
/* Height follows the content (no dead space under a short page); it
|
|
408
|
-
caps at 100dvh-24 (less
|
|
415
|
+
caps at 100dvh-24 (less both safe-area insets) and the options area
|
|
409
416
|
scrolls only then. */
|
|
410
417
|
height: auto !important;
|
|
411
|
-
max-height: min(800px, calc(100vh - 24px - var(--mnav-sat))) !important;
|
|
412
|
-
max-height: min(800px, calc(100dvh - 24px - var(--mnav-sat))) !important;
|
|
418
|
+
max-height: min(800px, calc(100vh - 24px - var(--mnav-sat) - var(--mnav-sab))) !important;
|
|
419
|
+
max-height: min(800px, calc(100dvh - 24px - var(--mnav-sat) - var(--mnav-sab))) !important;
|
|
413
420
|
flex-direction: column !important;
|
|
414
421
|
border-radius: 14px !important;
|
|
415
422
|
animation: dsh-mobile-nav-sheet-in .22s var(--ds-ease-out, ease-in-out);
|
|
@@ -457,14 +464,24 @@ export const LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
|
|
|
457
464
|
(config file + close) is reparented INTO this nav row by a client
|
|
458
465
|
effect (MobileNavOverlay), so the tab list must be anchored by its
|
|
459
466
|
class, NOT by :last-child (the reparented toolbar becomes the nav's
|
|
460
|
-
new last child).
|
|
467
|
+
new last child).
|
|
468
|
+
|
|
469
|
+
2026-10-21 (DSH 0.2.1-rc.2): this rule used to declare
|
|
470
|
+
"flex-wrap: wrap; overflow: visible" on top of the flex/min-width/gap
|
|
471
|
+
above. Because it is one class-anchored compound selector with a much
|
|
472
|
+
higher specificity than compat.css.ts's portal-scoped navList rule, it
|
|
473
|
+
silently won the tie and the tab list wrapped into two rows — with the
|
|
474
|
+
reparented ✕ vertically centred across both of them. Measured on a
|
|
475
|
+
phone shell: the list was 86px tall (two rows) instead of one 40px row,
|
|
476
|
+
and it scrolled nowhere. The wrap was a 2026-08-16 trade-off (show every
|
|
477
|
+
tab, no hidden scroll cut-off); with the thin scrollbar affordance now
|
|
478
|
+
in compat.css.ts the single scrolling row is the better trade, so the
|
|
479
|
+
two declarations live there and this rule no longer contradicts them. */
|
|
461
480
|
[aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])):not(:has([class*="ZuhsRW"])) > :first-child [class$="_navList"] {
|
|
462
481
|
flex: 1 1 auto !important;
|
|
463
482
|
min-width: 0 !important;
|
|
464
483
|
flex-direction: row !important;
|
|
465
|
-
flex-wrap: wrap !important;
|
|
466
484
|
gap: 6px !important;
|
|
467
|
-
overflow: visible !important;
|
|
468
485
|
}
|
|
469
486
|
/* Content toolbar (Open configuration file + close): grouped flush to
|
|
470
487
|
the right edge, and reparented INTO the nav row on mobile so it shares
|
|
@@ -310,4 +310,42 @@ option[value^="zr~"] {
|
|
|
310
310
|
html[data-zr-remote-session="1"] [data-slot="conversation.session.header.utilities"] [class$="_moreButton"] ~ [role="menu"] > [role="presentation"] > :first-child {
|
|
311
311
|
display: none !important;
|
|
312
312
|
}
|
|
313
|
+
|
|
314
|
+
/* ---------- remote session: the "save to this machine" action (2.2.6) ----------
|
|
315
|
+
One class, owned by this plugin (no host module class, no hash): the
|
|
316
|
+
deliverable-card entry renders it in the card's own actions cell, the
|
|
317
|
+
document-preview entry in the preview header where open-in-app's control
|
|
318
|
+
sits (that one is hidden remotely by the [data-open-target] rule above,
|
|
319
|
+
so this is the only action left there). Both entries only MOUNT for a
|
|
320
|
+
virtual session, so the html attribute gate is belt-and-braces, not the
|
|
321
|
+
decision.
|
|
322
|
+
The palette variables are the host's own (label-secondary / border-l1 /
|
|
323
|
+
interactive-bg-hover / state-error), which keeps light/dark identical to
|
|
324
|
+
the cards around it without copying a theme. */
|
|
325
|
+
html[data-zr-remote-session="1"] .zr-download-action {
|
|
326
|
+
box-sizing: border-box;
|
|
327
|
+
flex: none;
|
|
328
|
+
font: inherit;
|
|
329
|
+
font-size: 12px;
|
|
330
|
+
line-height: 18px;
|
|
331
|
+
color: var(--dsw-alias-label-secondary, #6b7280);
|
|
332
|
+
background: transparent;
|
|
333
|
+
border: 0.5px solid var(--dsw-alias-border-l1, #d9dde3);
|
|
334
|
+
border-radius: var(--dsw-radius-sm, 4px);
|
|
335
|
+
padding: 1px 8px;
|
|
336
|
+
cursor: pointer;
|
|
337
|
+
white-space: nowrap;
|
|
338
|
+
}
|
|
339
|
+
html[data-zr-remote-session="1"] .zr-download-action:hover {
|
|
340
|
+
color: var(--dsw-alias-label-primary, #111827);
|
|
341
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
|
|
342
|
+
}
|
|
343
|
+
html[data-zr-remote-session="1"] .zr-download-action[data-phase="busy"] {
|
|
344
|
+
cursor: progress;
|
|
345
|
+
opacity: 0.7;
|
|
346
|
+
}
|
|
347
|
+
html[data-zr-remote-session="1"] .zr-download-action[data-phase="error"] {
|
|
348
|
+
color: var(--dsw-alias-state-error-primary, #dc2626);
|
|
349
|
+
border-color: var(--dsw-alias-state-error-primary, #dc2626);
|
|
350
|
+
}
|
|
313
351
|
`
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote-session drag-and-drop decision core (RD-1): the DOM-free, readable
|
|
3
|
+
* half of the document-level drop guard (src/client/effects/remote-drop.ts).
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: the desktop app's attachment pipeline has a same-machine
|
|
6
|
+
* zero-copy fast path — the Electron preload exposes `__DSH_HOST_PATHS__`,
|
|
7
|
+
* and the host's `addFiles` turns a File that still carries a real disk path
|
|
8
|
+
* into an `@/absolute/path` mention chip WITHOUT uploading any bytes. In a
|
|
9
|
+
* remote session (`zr~<serverId>~<id>`) that path names a file on the
|
|
10
|
+
* SUB-CLIENT's machine; the server-side session can never read it, so the
|
|
11
|
+
* mention lands broken. The fix re-shapes the drop before the host sees it:
|
|
12
|
+
* strip the internal path by re-wrapping each File (`new File([f], ...)`) and
|
|
13
|
+
* redispatch a synthetic, untrusted DragEvent carrying them in a fresh
|
|
14
|
+
* DataTransfer. The host's own drop handler then treats it as an ordinary
|
|
15
|
+
* browser drag — the byte-upload branch — which the T51 fetch-route
|
|
16
|
+
* interception already relays to the server for staging.
|
|
17
|
+
*
|
|
18
|
+
* This module only DECIDES; it never touches the DOM:
|
|
19
|
+
* - `isRemoteSessionId` — the current-session gate, judged by the same
|
|
20
|
+
* `zr~` skeleton (virtual-id.ts) every other remote gate in this plugin
|
|
21
|
+
* keys on, so the two ends cannot drift;
|
|
22
|
+
* - `decideRemoteDrop` — the three trigger gates plus the directory rule,
|
|
23
|
+
* as a pure function over injectable data so the whole truth table is
|
|
24
|
+
* assertable in Node (scripts/check-remote-drop.mjs);
|
|
25
|
+
* - `rewrapFiles` — the File re-wrap itself (Node >= 20 has File).
|
|
26
|
+
*
|
|
27
|
+
* Misjudgement direction is safe by construction: every gate failing open
|
|
28
|
+
* lands on `passthrough` = today's behavior. The only wrong action a stale
|
|
29
|
+
* current-session signal can produce is re-shaping a LOCAL session's drop
|
|
30
|
+
* into an upload — bytes travel that did not have to, but nothing is lost.
|
|
31
|
+
* The reverse (a remote drop reaching the host untouched) is what loses the
|
|
32
|
+
* attachment, which is why all three gates must affirmatively pass.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { isVirtual } from '../virtual-id.ts'
|
|
36
|
+
|
|
37
|
+
/** True when the id is one of this plugin's virtual (relay) session ids —
|
|
38
|
+
* the `zr~<serverId>~<id>` form. `undefined` (no readable current-session
|
|
39
|
+
* signal) is deliberately NOT remote: the conservative gate reads a missing
|
|
40
|
+
* signal as a local session and never intervenes. */
|
|
41
|
+
export function isRemoteSessionId(id: string | undefined): boolean {
|
|
42
|
+
return typeof id === 'string' && isVirtual(id)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One drag item, reduced to what the decision needs. `kind` mirrors the
|
|
46
|
+
* DataTransferItem kind — only `file` items can carry attachment bytes.
|
|
47
|
+
* `isDirectory` comes from `webkitGetAsEntry().isDirectory`; a file item
|
|
48
|
+
* whose entry cannot be read counts as NOT a directory (the host applies
|
|
49
|
+
* the same default). */
|
|
50
|
+
export interface RemoteDropItemDescriptor {
|
|
51
|
+
kind: 'file' | 'other'
|
|
52
|
+
isDirectory: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Everything the decision may look at, injectable so no DOM is needed. */
|
|
56
|
+
export interface RemoteDropInput {
|
|
57
|
+
/** `__DSH_HOST_PATHS__` exists — the Electron preload's zero-copy bridge.
|
|
58
|
+
* False on phone / plain-browser hosts, which already upload bytes. */
|
|
59
|
+
hasHostPaths: boolean
|
|
60
|
+
/** The currently open session id, or `undefined` when no signal. */
|
|
61
|
+
currentSessionId: string | undefined
|
|
62
|
+
/** `event.isTrusted` — our own redispatched events are untrusted; this
|
|
63
|
+
* gate is the re-entry guard. */
|
|
64
|
+
trusted: boolean
|
|
65
|
+
/** Descriptor per file-kind drag item. `undefined` means the entry API
|
|
66
|
+
* (`webkitGetAsEntry`) is unavailable, so directories cannot be detected —
|
|
67
|
+
* the host's own semantics treat that as "no directories". */
|
|
68
|
+
items: readonly RemoteDropItemDescriptor[] | undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type RemoteDropAction = 'passthrough' | 'redispatch' | 'block-directories'
|
|
72
|
+
|
|
73
|
+
/** Stable machine-readable why, one per action branch. Ordered by the gate
|
|
74
|
+
* sequence below. */
|
|
75
|
+
export type RemoteDropReason =
|
|
76
|
+
| 'untrusted-event'
|
|
77
|
+
| 'no-host-paths'
|
|
78
|
+
| 'not-remote-session'
|
|
79
|
+
| 'no-entry-api'
|
|
80
|
+
| 'contains-directory'
|
|
81
|
+
| 'no-files'
|
|
82
|
+
| 'remote-files'
|
|
83
|
+
|
|
84
|
+
export interface RemoteDropDecision {
|
|
85
|
+
action: RemoteDropAction
|
|
86
|
+
reason: RemoteDropReason
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The three trigger gates, then the directory rule. Every gate must
|
|
91
|
+
* affirmatively pass before anything is intervened on:
|
|
92
|
+
* 1. `trusted` — synthetic (our own) events pass straight through, which is
|
|
93
|
+
* what makes the redispatch loop-free;
|
|
94
|
+
* 2. `hasHostPaths` — without the Electron bridge the host is already on
|
|
95
|
+
* the byte-upload branch and there is nothing to fix;
|
|
96
|
+
* 3. remote session — a missing/failed current-session read counts as local
|
|
97
|
+
* (conservative: passthrough keeps today's behavior);
|
|
98
|
+
* 4. no entry API — redispatch (no directory knowledge, same default as the
|
|
99
|
+
* host);
|
|
100
|
+
* 5. any directory — `block-directories`, which takes precedence over
|
|
101
|
+
* redispatch: a folder cannot be fixed by single-file upload, so the
|
|
102
|
+
* whole drop is stopped with a toast rather than silently reduced;
|
|
103
|
+
* 6. no file items at all — passthrough (the host's own `Files`-in-types
|
|
104
|
+
* gate would ignore the drop; there is nothing to re-shape);
|
|
105
|
+
* 7. otherwise — `redispatch`.
|
|
106
|
+
*/
|
|
107
|
+
export function decideRemoteDrop(input: RemoteDropInput): RemoteDropDecision {
|
|
108
|
+
if (!input.trusted) return { action: 'passthrough', reason: 'untrusted-event' }
|
|
109
|
+
if (!input.hasHostPaths) return { action: 'passthrough', reason: 'no-host-paths' }
|
|
110
|
+
if (!isRemoteSessionId(input.currentSessionId)) return { action: 'passthrough', reason: 'not-remote-session' }
|
|
111
|
+
if (input.items === undefined) return { action: 'redispatch', reason: 'no-entry-api' }
|
|
112
|
+
if (input.items.some((item) => item.isDirectory)) return { action: 'block-directories', reason: 'contains-directory' }
|
|
113
|
+
if (!input.items.some((item) => item.kind === 'file')) return { action: 'passthrough', reason: 'no-files' }
|
|
114
|
+
return { action: 'redispatch', reason: 'remote-files' }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Re-wrap files so they stop carrying their disk paths. A File the browser
|
|
119
|
+
* constructed from an OS drop keeps a hidden reference the Electron
|
|
120
|
+
* `getPathForFile` can read; `new File([f], ...)` builds a JS-constructed
|
|
121
|
+
* File with no such backing — `getPathForFile` returns "" for it, which
|
|
122
|
+
* sends the host's `addFiles` down the byte-upload branch. name / type /
|
|
123
|
+
* lastModified are preserved so the staged upload keeps its filename.
|
|
124
|
+
*/
|
|
125
|
+
export function rewrapFiles(files: Iterable<File>): File[] {
|
|
126
|
+
return Array.from(files, (file) => new File([file], file.name, { type: file.type, lastModified: file.lastModified }))
|
|
127
|
+
}
|
|
@@ -163,6 +163,9 @@ export interface ClientStatusBody {
|
|
|
163
163
|
* a host role or a refused install may still answer it; a server without
|
|
164
164
|
* the field never does). */
|
|
165
165
|
pluginRouteIntercept?: unknown
|
|
166
|
+
/** RD-6: the upload-redirect service probe (presence-gated — an older
|
|
167
|
+
* plugin answers without it). */
|
|
168
|
+
serviceProbe?: unknown
|
|
166
169
|
}
|
|
167
170
|
|
|
168
171
|
/** One remote-call failure line in the diagnostics lists. `time` is epoch
|
|
@@ -217,6 +220,10 @@ export interface ClientConnectionView {
|
|
|
217
220
|
* renders the 本机改道未启用 line with the shape reasons; undefined when
|
|
218
221
|
* the body carried none). */
|
|
219
222
|
pluginRouteIntercept: { installed: boolean; reasons: string[] } | undefined
|
|
223
|
+
/** RD-6: the upload-redirect service probe from the same body, passed
|
|
224
|
+
* through tolerantly (undefined when the body carried none — an older
|
|
225
|
+
* plugin — and the diagnostics row stays hidden). */
|
|
226
|
+
uploadProbe: Readonly<Record<string, unknown>> | undefined
|
|
220
227
|
}
|
|
221
228
|
|
|
222
229
|
const CLIENT_STATES: readonly ClientConnectionView['state'][] = ['unpaired', 'connected', 'revoked', 'unreachable', 'unexpected', 'invalid-url', 'incompatible']
|
|
@@ -333,6 +340,9 @@ export function deriveClientStatusView(body: ClientStatusBody): ClientConnection
|
|
|
333
340
|
compat: deriveCompatView(safe.compat),
|
|
334
341
|
pluginBridge: derivePluginBridgeStatuses(safe.pluginBridge),
|
|
335
342
|
pluginRouteIntercept: derivePluginRouteInterceptView(safe.pluginRouteIntercept),
|
|
343
|
+
uploadProbe: safe.serviceProbe !== null && typeof safe.serviceProbe === 'object'
|
|
344
|
+
? Object.freeze({ ...(safe.serviceProbe as Record<string, unknown>) })
|
|
345
|
+
: undefined,
|
|
336
346
|
}
|
|
337
347
|
}
|
|
338
348
|
|
package/src/client-routes.ts
CHANGED
|
@@ -104,6 +104,12 @@ export interface ClientHandlerOptions {
|
|
|
104
104
|
getRowConfig: () => unknown
|
|
105
105
|
/** Outbound fetch, defaulting to the global one. */
|
|
106
106
|
fetchImpl?: FetchLike
|
|
107
|
+
/** Upload-redirect service probe (RD-6): lazily reflects `connection` /
|
|
108
|
+
* `fileUploads` from a context that already resolved, plus whether the
|
|
109
|
+
* fetch-route inject callback ever fired. Answers WHY the upload
|
|
110
|
+
* redirection is absent on compositions where one of the two services
|
|
111
|
+
* never becomes available (cordis keeps such injects silently PENDING). */
|
|
112
|
+
getServiceProbe?: () => Record<string, unknown>
|
|
107
113
|
/** The live relay client (T23a), once apply() built one. When it has LEFT
|
|
108
114
|
* its initial `unpaired` state — a connection attempt ran — the status
|
|
109
115
|
* route reports ITS verdict (state + handshake server name) and skips the
|
|
@@ -469,6 +475,7 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
469
475
|
// that.
|
|
470
476
|
const intercept = options.getIntercept?.()
|
|
471
477
|
const fetchRouteIntercept = options.getFetchRouteIntercept?.()
|
|
478
|
+
const serviceProbe = options.getServiceProbe?.()
|
|
472
479
|
const pluginRouteIntercept = options.getPluginRouteIntercept?.()
|
|
473
480
|
const compatRelay = options.getRelayClient?.()
|
|
474
481
|
const compat =
|
|
@@ -487,6 +494,7 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
487
494
|
let next: Record<string, unknown> = { ...body, pluginBridge: options.getPluginBridgeStatus?.() ?? [] }
|
|
488
495
|
if (intercept !== undefined) next = { ...next, intercept }
|
|
489
496
|
if (fetchRouteIntercept !== undefined) next = { ...next, fetchRouteIntercept }
|
|
497
|
+
if (serviceProbe !== undefined) next = { ...next, serviceProbe }
|
|
490
498
|
if (pluginRouteIntercept !== undefined) next = { ...next, pluginRouteIntercept }
|
|
491
499
|
return compat === undefined ? next : { ...next, compat }
|
|
492
500
|
}
|