dsh-zen-remote 2.3.5 → 2.3.7
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 +71 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/docs/interface.md +12 -6
- package/docs/plugin-bridge.md +2 -2
- package/docs/remote-access.en.md +9 -3
- package/docs/remote-access.md +9 -3
- package/lan-gate.mjs +7 -0
- package/lib/adapters/dsh-better-sidebar/index.js +31 -2
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- 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 +589 -97
- 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 +306 -45
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +43 -1
- package/lib/intercept.js.map +1 -1
- package/lib/log-ring.js +76 -0
- package/lib/log-ring.js.map +1 -0
- package/lib/plugin-adapter-core.js +30 -1
- package/lib/plugin-adapter-core.js.map +1 -1
- package/lib/plugin-route-intercept.js +506 -11
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/plugin-tunnel.js +210 -18
- package/lib/plugin-tunnel.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +5 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts.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 +10 -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/content.css.d.ts +1 -1
- package/lib/types/client/styles/content.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/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/intercept.d.ts +6 -0
- package/lib/types/intercept.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/plugin-adapter-core.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +8 -0
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/plugin-tunnel.d.ts +37 -2
- package/lib/types/plugin-tunnel.d.ts.map +1 -1
- package/package.json +3 -2
- package/pwa/app.css +13 -2
- package/scripts/check-debug-report.mjs +277 -0
- package/scripts/check-info-sheet-stacking.mjs +85 -0
- package/scripts/check-remote-drop.mjs +130 -0
- package/scripts/check-tablet-safe-area.mjs +49 -0
- package/src/adapters/dsh-better-sidebar/index.ts +31 -2
- package/src/client/MobileSessionInfo.tsx +29 -2
- package/src/client/effects/remote-drop.ts +271 -24
- package/src/client/locales.ts +24 -4
- package/src/client/settings/SettingsSection.tsx +120 -3
- package/src/client/styles/content.css.ts +44 -14
- package/src/client/styles/layout.css.ts +9 -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 +295 -43
- package/src/intercept.ts +46 -0
- package/src/log-ring.ts +101 -0
- package/src/plugin-adapter-core.ts +29 -2
- package/src/plugin-route-intercept.ts +516 -12
- package/src/plugin-tunnel.ts +230 -20
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
ADMIN_STATUS_ROUTE,
|
|
46
46
|
CLIENT_CLAIM_ROUTE,
|
|
47
47
|
CLIENT_CONFIG_ROUTE,
|
|
48
|
+
CLIENT_DEBUG_REPORT_ROUTE,
|
|
48
49
|
CLIENT_RECONNECT_ROUTE,
|
|
49
50
|
CLIENT_STATUS_ROUTE,
|
|
50
51
|
claimDeviceNameOf,
|
|
@@ -58,6 +59,8 @@ import {
|
|
|
58
59
|
} from '../../client-data/settings-form.ts'
|
|
59
60
|
import { bridgeSectionState, togglePluginConsent } from '../../client-data/plugin-bridge.ts'
|
|
60
61
|
import type { PluginBridgeStatusView } from '../../client-data/plugin-bridge.ts'
|
|
62
|
+
import { CURRENT_SESSION_STORAGE_KEY, parseCurrentSessionStorage } from '../../client-data/current-session.ts'
|
|
63
|
+
import { isRemoteSessionId } from '../../client-data/remote-drop.ts'
|
|
61
64
|
import type {
|
|
62
65
|
AdminStatusBody,
|
|
63
66
|
ClaimRouteBody,
|
|
@@ -139,6 +142,45 @@ async function postJson(url: string, payload: Record<string, unknown>): Promise<
|
|
|
139
142
|
}
|
|
140
143
|
}
|
|
141
144
|
|
|
145
|
+
/** CU-4: the export's download name, `zen-remote-debug-<role>-<yyyymmdd-hhmm>.json`,
|
|
146
|
+
* stamped in the browser's local time (the file is for the user's own
|
|
147
|
+
* folder; the report's `_meta.generatedAt` carries the exact UTC instant). */
|
|
148
|
+
function debugExportFileName(role: 'host' | 'client', now: Date): string {
|
|
149
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
150
|
+
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`
|
|
151
|
+
return `zen-remote-debug-${role}-${stamp}.json`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** CU-4: one transient notice for the export's outcome — the same inline-
|
|
155
|
+
* styled overlay habit the remote-drop guard's folder toast uses (styles
|
|
156
|
+
* stay inline: nothing of this goes into pwa/app.css or the style sheets),
|
|
157
|
+
* one at a time, gone after 3 s, never intercepting taps. */
|
|
158
|
+
let debugToast: HTMLDivElement | undefined
|
|
159
|
+
let debugToastTimer: number | undefined
|
|
160
|
+
function showDebugToast(text: string): void {
|
|
161
|
+
if (debugToastTimer !== undefined) window.clearTimeout(debugToastTimer)
|
|
162
|
+
debugToast?.remove()
|
|
163
|
+
const el = document.createElement('div')
|
|
164
|
+
el.dataset.zenRemote = 'debug-export-toast'
|
|
165
|
+
el.textContent = text
|
|
166
|
+
el.style.cssText = [
|
|
167
|
+
'position:fixed', 'left:50%', 'transform:translateX(-50%)',
|
|
168
|
+
'bottom:calc(env(safe-area-inset-bottom, 0px) + 32px)',
|
|
169
|
+
'z-index:2147483000',
|
|
170
|
+
'background:rgba(0,0,0,.82)', 'color:#fff',
|
|
171
|
+
'font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
|
|
172
|
+
'padding:10px 16px', 'border-radius:10px', 'max-width:90vw', 'text-align:center',
|
|
173
|
+
'pointer-events:none',
|
|
174
|
+
].join(';')
|
|
175
|
+
document.body.appendChild(el)
|
|
176
|
+
debugToast = el
|
|
177
|
+
debugToastTimer = window.setTimeout(() => {
|
|
178
|
+
el.remove()
|
|
179
|
+
if (debugToast === el) debugToast = undefined
|
|
180
|
+
debugToastTimer = undefined
|
|
181
|
+
}, 3000)
|
|
182
|
+
}
|
|
183
|
+
|
|
142
184
|
export function SettingsSection(props: SettingsSectionProps) {
|
|
143
185
|
// The summary view is a one-liner only — it must not even fetch
|
|
144
186
|
// admin/status, so every hook (and the fetch) lives in the page component.
|
|
@@ -228,6 +270,8 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
|
|
|
228
270
|
// JS dialog methods, so renames edit inline).
|
|
229
271
|
const [renaming, setRenaming] = useState<{ id: string, draft: string } | null>(null)
|
|
230
272
|
const [pushTest, setPushTest] = useState<PushTest>({ state: 'idle' })
|
|
273
|
+
// CU-4: the debug export's in-flight marker.
|
|
274
|
+
const [debugBusy, setDebugBusy] = useState(false)
|
|
231
275
|
|
|
232
276
|
// The client group's pairing form (T16). The address prefills from the row
|
|
233
277
|
// (once the shared form's document is served) until the user types into it;
|
|
@@ -571,6 +615,48 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
|
|
|
571
615
|
setPushTest(result.ok ? { state: 'ok', sent: result.sent, failed: result.failed } : { state: 'fail' })
|
|
572
616
|
}
|
|
573
617
|
|
|
618
|
+
// CU-4 导出诊断信息: the backend's redacted report (both roles answer
|
|
619
|
+
// their own), merged with the two facts only THIS page can read — the
|
|
620
|
+
// attachment-entry guard's browser-side counters and whether the session
|
|
621
|
+
// open right now is a remote one — then downloaded as one JSON file. The
|
|
622
|
+
// browser plate carries counters and a boolean, nothing else: no session
|
|
623
|
+
// id, no storage dump.
|
|
624
|
+
const runDebugExport = async (): Promise<void> => {
|
|
625
|
+
setDebugBusy(true)
|
|
626
|
+
try {
|
|
627
|
+
const res = await fetch(CLIENT_DEBUG_REPORT_ROUTE)
|
|
628
|
+
if (!res.ok) throw new StatusError(res.status)
|
|
629
|
+
const report = await res.json() as Record<string, unknown>
|
|
630
|
+
let remote = false
|
|
631
|
+
try {
|
|
632
|
+
const current = parseCurrentSessionStorage(window.localStorage.getItem(CURRENT_SESSION_STORAGE_KEY))
|
|
633
|
+
remote = current.kind === 'open' && isRemoteSessionId(current.sessionId)
|
|
634
|
+
} catch { /* storage unreadable: report not-remote */ }
|
|
635
|
+
const merged = {
|
|
636
|
+
...report,
|
|
637
|
+
browser: {
|
|
638
|
+
remoteDrop: remoteDropDiagnostics(),
|
|
639
|
+
currentSession: { remote },
|
|
640
|
+
},
|
|
641
|
+
}
|
|
642
|
+
const blob = new Blob([`${JSON.stringify(merged, null, 2)}\n`], { type: 'application/json' })
|
|
643
|
+
const url = URL.createObjectURL(blob)
|
|
644
|
+
const anchor = document.createElement('a')
|
|
645
|
+
anchor.href = url
|
|
646
|
+
anchor.download = debugExportFileName(clientRole ? 'client' : 'host', new Date())
|
|
647
|
+
document.body.appendChild(anchor)
|
|
648
|
+
anchor.click()
|
|
649
|
+
anchor.remove()
|
|
650
|
+
// Revoked on the next tick: some engines start the download only
|
|
651
|
+
// after the click handler returns.
|
|
652
|
+
window.setTimeout(() => { URL.revokeObjectURL(url) }, 0)
|
|
653
|
+
showDebugToast(t('settings.debug.done'))
|
|
654
|
+
} catch {
|
|
655
|
+
showDebugToast(t('settings.debug.fail'))
|
|
656
|
+
}
|
|
657
|
+
setDebugBusy(false)
|
|
658
|
+
}
|
|
659
|
+
|
|
574
660
|
// --- client group (T16) ----------------------------------------------------
|
|
575
661
|
|
|
576
662
|
// The address prefill reads the row's stored serverUrl while the user has
|
|
@@ -1201,9 +1287,10 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
|
|
|
1201
1287
|
)}
|
|
1202
1288
|
</>
|
|
1203
1289
|
)}
|
|
1204
|
-
{/* RD-3: the
|
|
1205
|
-
plus the five counters
|
|
1206
|
-
|
|
1290
|
+
{/* RD-3: the attachment-entry guard's own line — installed state
|
|
1291
|
+
plus the five counters, with the per-entry split below it
|
|
1292
|
+
(AU-2: drop / picker / paste). Browser-side values, so unlike
|
|
1293
|
+
the rows above it never rides the client/status body. */}
|
|
1207
1294
|
{(dropDiag.installed || dropDiag.notInstalledReason !== undefined) && (
|
|
1208
1295
|
<div className="zr-settings-field">
|
|
1209
1296
|
<div className="zr-settings-head">
|
|
@@ -1220,6 +1307,18 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
|
|
|
1220
1307
|
})
|
|
1221
1308
|
: t('settings.client.diagRemoteDropOff', { reason: dropDiag.notInstalledReason ?? '—' })}
|
|
1222
1309
|
</p>
|
|
1310
|
+
{dropDiag.installed && (
|
|
1311
|
+
<p className="zr-settings-hint">
|
|
1312
|
+
{t('settings.client.diagRemoteDropEntries', {
|
|
1313
|
+
dropI: dropDiag.interceptedBy.drop,
|
|
1314
|
+
pickerI: dropDiag.interceptedBy.picker,
|
|
1315
|
+
pasteI: dropDiag.interceptedBy.paste,
|
|
1316
|
+
dropP: dropDiag.passthroughBy.drop,
|
|
1317
|
+
pickerP: dropDiag.passthroughBy.picker,
|
|
1318
|
+
pasteP: dropDiag.passthroughBy.paste,
|
|
1319
|
+
})}
|
|
1320
|
+
</p>
|
|
1321
|
+
)}
|
|
1223
1322
|
</div>
|
|
1224
1323
|
)}
|
|
1225
1324
|
{/* RD-6: the upload-redirect (fetch-route intercept) line — rides
|
|
@@ -1300,6 +1399,24 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
|
|
|
1300
1399
|
)}
|
|
1301
1400
|
</div>
|
|
1302
1401
|
)}
|
|
1402
|
+
|
|
1403
|
+
{/* CU-4: the diagnostics export — the very bottom of the page, on
|
|
1404
|
+
BOTH roles (a host's report has its own value: the gateway plate),
|
|
1405
|
+
outside the form frame so it works while the namespace is
|
|
1406
|
+
read-only. Existing classes only: no new style rules anywhere. */}
|
|
1407
|
+
<div className="zr-settings-card">
|
|
1408
|
+
<div className="zr-settings-field">
|
|
1409
|
+
<div className="zr-settings-head">
|
|
1410
|
+
{/* The row's control is a Button (not labelable): a span, not a control-less label. */}
|
|
1411
|
+
<span className="zr-settings-head-title">{t('settings.debug.title')}</span>
|
|
1412
|
+
<span style={{ flex: 1 }} />
|
|
1413
|
+
<Button variant="outline" size="sm" disabled={debugBusy} onClick={() => { void runDebugExport() }}>
|
|
1414
|
+
{debugBusy ? t('settings.debug.exporting') : t('settings.debug.export')}
|
|
1415
|
+
</Button>
|
|
1416
|
+
</div>
|
|
1417
|
+
<p className="zr-settings-hint">{t('settings.debug.hint')}</p>
|
|
1418
|
+
</div>
|
|
1419
|
+
</div>
|
|
1303
1420
|
</div>
|
|
1304
1421
|
)
|
|
1305
1422
|
}
|
|
@@ -84,15 +84,25 @@ export const CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ------
|
|
|
84
84
|
max-width: none !important;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
/* Turn-tail metadata (2026-08-18
|
|
88
|
-
|
|
89
|
-
[data-time-hover-root] gate holds
|
|
90
|
-
:focus-within) — a phone never hovers, so
|
|
91
|
-
|
|
92
|
-
plugin's own
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
87
|
+
/* Turn-tail metadata (2026-08-18; reshaped 2026-10-30 for DSH 0.2.x): the
|
|
88
|
+
tail's 24h clock is desktop-hover-revealed (official
|
|
89
|
+
[data-time-hover-root] gate holds it at opacity 0 until :hover /
|
|
90
|
+
:focus-within) — a phone never hovers, so it was simply invisible; and
|
|
91
|
+
the row is a fixed-height nowrap + overflow:hidden flex line (plus this
|
|
92
|
+
plugin's own clamp in layout.css.ts), which clipped it.
|
|
93
|
+
|
|
94
|
+
DSH 0.2.x (MessageIconActions, dsh-client-ui-chat) groups the usage
|
|
95
|
+
button (TurnUsagePanel) and the clock (_timeEnd) inside ONE inline-flex
|
|
96
|
+
_endInfo (min-width: 0; align-items: center; gap: 8px; margin-left: 8px)
|
|
97
|
+
that never wraps internally. The 0.1.x-era rule here gave _timeEnd
|
|
98
|
+
flex-basis: 100% + white-space: normal — inside _endInfo that no longer
|
|
99
|
+
reads as "own full-width line", it reads as "shrink to nothing and
|
|
100
|
+
break", and with the transcript-wide overflow-wrap: anywhere above,
|
|
101
|
+
"18:06" rendered as two lines. On phones now: always visible; the row
|
|
102
|
+
may grow in height and wrap; _endInfo drops to the next line as one
|
|
103
|
+
unit when icons + usage + clock do not fit; the clock itself never
|
|
104
|
+
shrinks or breaks. !important + the extra [class$="_actions"] hop
|
|
105
|
+
out-specifies both upstream and layout.css.ts. */
|
|
96
106
|
/* Pin the tail row's width chain before allowing wrap: with wrap enabled
|
|
97
107
|
the row's intrinsic (shrink-to-fit) width collapses to its widest
|
|
98
108
|
single icon, taking the whole tail down to ~16px. */
|
|
@@ -100,9 +110,26 @@ export const CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ------
|
|
|
100
110
|
[data-chat-flow-kind="turn-tail"] [class$="_actions"] {
|
|
101
111
|
width: 100%;
|
|
102
112
|
}
|
|
113
|
+
/* height: auto releases the upstream fixed height: calc(28px + …);
|
|
114
|
+
overflow: visible beats layout.css.ts's overflow: hidden (same
|
|
115
|
+
specificity — this file is concatenated last, so it wins the tie). A
|
|
116
|
+
wrapped second line must not be cut off vertically; min-height keeps
|
|
117
|
+
the one-line case at the upstream height so nothing shifts when it
|
|
118
|
+
fits. */
|
|
103
119
|
[data-chat-flow-kind="turn-tail"] [class$="_actions"] {
|
|
104
120
|
flex-wrap: wrap;
|
|
105
121
|
row-gap: 2px;
|
|
122
|
+
height: auto !important;
|
|
123
|
+
min-height: 28px;
|
|
124
|
+
overflow: visible !important;
|
|
125
|
+
}
|
|
126
|
+
/* _endInfo moves as a whole: flex: none so the usage + clock group is
|
|
127
|
+
never asked to shrink — when the row is too narrow the entire group
|
|
128
|
+
wraps to the next line instead of compressing the clock inside it.
|
|
129
|
+
max-width keeps an unusually long usage label inside the column. */
|
|
130
|
+
[data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_endInfo"] {
|
|
131
|
+
flex: none;
|
|
132
|
+
max-width: 100%;
|
|
106
133
|
}
|
|
107
134
|
/* Wide companion to the rule below: mirrors the upstream hover-gate
|
|
108
135
|
selector shape directly, with no assumptions about the tail's DOM
|
|
@@ -113,13 +140,16 @@ export const CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ------
|
|
|
113
140
|
opacity: 1 !important;
|
|
114
141
|
transition: none !important;
|
|
115
142
|
}
|
|
143
|
+
/* overflow-wrap / word-break: normal undo the [data-chat-flow]
|
|
144
|
+
overflow-wrap: anywhere inherited from the top of this block — that is
|
|
145
|
+
the rule that let "18:06" break between digits once its box was
|
|
146
|
+
squeezed. */
|
|
116
147
|
[data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_timeEnd"] {
|
|
117
148
|
opacity: 1 !important;
|
|
118
|
-
flex
|
|
119
|
-
white-space:
|
|
120
|
-
overflow:
|
|
121
|
-
|
|
122
|
-
padding-left: 0 !important;
|
|
149
|
+
flex: none !important;
|
|
150
|
+
white-space: nowrap !important;
|
|
151
|
+
overflow-wrap: normal !important;
|
|
152
|
+
word-break: normal !important;
|
|
123
153
|
font-size: 12px;
|
|
124
154
|
line-height: 18px;
|
|
125
155
|
}
|
|
@@ -139,12 +139,18 @@ export const LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
|
|
|
139
139
|
[data-phase] [class$="_actions"] {
|
|
140
140
|
overflow: hidden !important;
|
|
141
141
|
}
|
|
142
|
+
/* The 24h clock ("18:06" / "MM-DD 18:06") is a fixed-size item: it must
|
|
143
|
+
never be the thing that shrinks. DSH 0.2.x wraps it with the usage
|
|
144
|
+
button in an inline-flex _endInfo (min-width: 0), so a shrinkable
|
|
145
|
+
time (the old flex: 0 1 auto + min-width: 0) got squeezed inside that
|
|
146
|
+
non-wrapping box and "18:06" broke into two lines. flex: none pins its
|
|
147
|
+
width; the turn-tail rules in content.css.ts let _endInfo wrap as a
|
|
148
|
+
whole instead. */
|
|
142
149
|
[data-phase] [class$="_actions"] [class$="_timeEnd"] {
|
|
143
|
-
flex:
|
|
144
|
-
|
|
150
|
+
flex: none !important;
|
|
151
|
+
white-space: nowrap !important;
|
|
145
152
|
overflow: hidden !important;
|
|
146
153
|
text-overflow: ellipsis !important;
|
|
147
|
-
white-space: nowrap !important;
|
|
148
154
|
}
|
|
149
155
|
|
|
150
156
|
[data-phase] [class$="_scroll"]:has(p) {
|
|
@@ -125,4 +125,42 @@ export const MISC_CSS = ` /* ---------- hero composer on mobile ----------
|
|
|
125
125
|
display: none !important;
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
+
|
|
129
|
+
/* ---------- tablet standalone PWA: pay the safe area above 1023px ---------- */
|
|
130
|
+
/* The gateway serves viewport-fit=cover to every non-desktop device (see
|
|
131
|
+
coverViewport in lan-gate-server), phone-chrome only manages the meta at
|
|
132
|
+
<=1023px, and every compensation rule in layout.css lives behind the same
|
|
133
|
+
1023px breakpoint and the data-mobile-nav frame marker MobileNavOverlay
|
|
134
|
+
only sets there. A wide tablet's home-screen PWA — iPad landscape, the
|
|
135
|
+
12.9"/13" iPad portrait at exactly 1024, Android tablets — therefore
|
|
136
|
+
painted under the status bar with nothing paying the inset: the top row
|
|
137
|
+
of controls sat in the system area and was hard to tap.
|
|
138
|
+
|
|
139
|
+
Anchor: the official AppFrame (ui-layout AppFrame.tsx) is the direct
|
|
140
|
+
parent of the [data-shell-overlay] layer — the same element
|
|
141
|
+
MobileNavOverlay's findFrame() resolves — and the frame already pads
|
|
142
|
+
ITSELF for the Windows titlebar (padding-top: --dsh-windows-titlebar-height
|
|
143
|
+
in AppFrame.module.css), so padding the frame is the host's own pattern
|
|
144
|
+
for system bars.
|
|
145
|
+
|
|
146
|
+
Structural no-op everywhere else, by construction:
|
|
147
|
+
- display-mode: standalone matches only an installed PWA; desktop
|
|
148
|
+
browsers report browser.
|
|
149
|
+
- The Electron app never receives this stylesheet at all (the
|
|
150
|
+
isDesktopShell gate in client/index.tsx) and never gets cover.
|
|
151
|
+
- env() insets are hard 0 in every desktop browser, so even a browser
|
|
152
|
+
that somehow matched the media query computes 0px padding; on iPads
|
|
153
|
+
the bottom inset is 0 too (no home indicator) — the bottom line only
|
|
154
|
+
ever pays out on Android tablets with a gesture/nav bar.
|
|
155
|
+
box-sizing is load-bearing (the S2.1 lesson, see the frame rule in
|
|
156
|
+
layout.css): the official frame is height:100% content-box, and padding
|
|
157
|
+
without border-box would grow it past the viewport instead of reserving
|
|
158
|
+
the strip. Pinned by scripts/check-tablet-safe-area.mjs. */
|
|
159
|
+
@media (min-width: 1024px) and (display-mode: standalone) {
|
|
160
|
+
:has(> [data-shell-overlay]) {
|
|
161
|
+
box-sizing: border-box !important;
|
|
162
|
+
padding-top: var(--mnav-sat) !important;
|
|
163
|
+
padding-bottom: var(--mnav-sab) !important;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
128
166
|
`
|
|
@@ -125,3 +125,106 @@ export function decideRemoteDrop(input: RemoteDropInput): RemoteDropDecision {
|
|
|
125
125
|
export function rewrapFiles(files: Iterable<File>): File[] {
|
|
126
126
|
return Array.from(files, (file) => new File([file], file.name, { type: file.type, lastModified: file.lastModified }))
|
|
127
127
|
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// AU-1: the intake decision — the same three gates extended to the picker
|
|
131
|
+
// (hidden file input's `change`) and paste entries. issue #49: the drop guard
|
|
132
|
+
// alone left those two entries on the zero-copy path chip, so a REMOTE
|
|
133
|
+
// session's attachment named a file on the sub-client's disk. The decision
|
|
134
|
+
// keeps the drop semantics frozen in `decideRemoteDrop` (its truth table is a
|
|
135
|
+
// regression pin) and answers the two new entries here, where the fix's
|
|
136
|
+
// minimal-intervention rule can be expressed: only a file that CARRIES a path
|
|
137
|
+
// and is NOT an image takes the bad zero-copy branch (addFiles), so only
|
|
138
|
+
// those are worth intercepting — pathless and image files already upload
|
|
139
|
+
// bytes and must stay untouched.
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/** Which entry an intake item arrived through. `drop` keeps the redispatch
|
|
143
|
+
* semantics of {@link decideRemoteDrop} (any file intervenes); `picker` /
|
|
144
|
+
* `paste` intervene only on the path-carrying non-image files. */
|
|
145
|
+
export type RemoteIntakeEntry = 'drop' | 'picker' | 'paste'
|
|
146
|
+
|
|
147
|
+
/** One intake item, reduced to what the decision needs. `hasPath` is the
|
|
148
|
+
* bridge's `pathFor(file) !== ''` — the same fact the host's zero-copy
|
|
149
|
+
* branch keys on; `isImage` is `file.type` starting with `image/` (the
|
|
150
|
+
* host's own image classification). A `paste` item with no file behind it
|
|
151
|
+
* never becomes a descriptor (the effect only collects `kind: 'file'`
|
|
152
|
+
* clipboard items), so plain-text paste arrives as an EMPTY list. */
|
|
153
|
+
export interface RemoteIntakeDescriptor {
|
|
154
|
+
entry: RemoteIntakeEntry
|
|
155
|
+
isDirectory: boolean
|
|
156
|
+
hasPath: boolean
|
|
157
|
+
isImage: boolean
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Everything the intake decision may look at — the same three gates as
|
|
161
|
+
* {@link decideRemoteDrop}, injectable so no DOM is needed. */
|
|
162
|
+
export interface RemoteIntakeInput {
|
|
163
|
+
/** `__DSH_HOST_PATHS__` exists — the Electron preload's zero-copy bridge. */
|
|
164
|
+
hasHostPaths: boolean
|
|
165
|
+
/** The currently open session id, or `undefined` when no signal. */
|
|
166
|
+
currentSessionId: string | undefined
|
|
167
|
+
/** `event.isTrusted` — the re-entry guard for our redispatched events. */
|
|
168
|
+
trusted: boolean
|
|
169
|
+
/** Descriptor per collected file. `undefined` means the caller could not
|
|
170
|
+
* even enumerate the items — with no inventory there is nothing safe to
|
|
171
|
+
* judge, so this passes through (today's behavior). */
|
|
172
|
+
items: readonly RemoteIntakeDescriptor[] | undefined
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type RemoteIntakeAction = 'passthrough' | 'redirect' | 'block-directories'
|
|
176
|
+
|
|
177
|
+
/** Stable machine-readable why, one per branch, ordered by the gate sequence
|
|
178
|
+
* in {@link decideRemoteIntake}. */
|
|
179
|
+
export type RemoteIntakeReason =
|
|
180
|
+
| 'untrusted-event'
|
|
181
|
+
| 'no-host-paths'
|
|
182
|
+
| 'not-remote-session'
|
|
183
|
+
| 'no-entry-api'
|
|
184
|
+
| 'contains-directory'
|
|
185
|
+
| 'no-files'
|
|
186
|
+
| 'no-path-or-image-only'
|
|
187
|
+
| 'remote-path-files'
|
|
188
|
+
|
|
189
|
+
export interface RemoteIntakeDecision {
|
|
190
|
+
action: RemoteIntakeAction
|
|
191
|
+
reason: RemoteIntakeReason
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The three gates, then the intervention rule. The gates are decideRemoteDrop's
|
|
196
|
+
* verbatim (all must affirmatively pass — a missing signal reads as local and
|
|
197
|
+
* never intervenes); the rule after them:
|
|
198
|
+
* 1. `items === undefined` — no inventory, pass through (`no-entry-api`;
|
|
199
|
+
* deciding blind could eat a working flow);
|
|
200
|
+
* 2. any directory — `block-directories`, ahead of every redirect: a folder
|
|
201
|
+
* cannot be fixed by single-file upload, the whole intake is stopped with
|
|
202
|
+
* a toast (same semantics as the drop branch);
|
|
203
|
+
* 3. no items at all — passthrough (`no-files`; the plain-text paste shape);
|
|
204
|
+
* 4. otherwise — `redirect` when ANY item is a `drop` item (the frozen
|
|
205
|
+
* "any file intervenes" semantics) or carries a path while not being an
|
|
206
|
+
* image (the zero-copy bad branch — exactly what issue #49 broke on);
|
|
207
|
+
* `no-path-or-image-only` passthrough when none does.
|
|
208
|
+
*
|
|
209
|
+
* Drop-entry parity pin: a `drop` item's intervention semantics here are
|
|
210
|
+
* kept EQUIVALENT to `decideRemoteDrop` — same gates, directory rule and
|
|
211
|
+
* "any file intervenes" — with exactly ONE deliberate divergence: when
|
|
212
|
+
* `items === undefined`, `decideRemoteDrop` redispatches (a drop always has
|
|
213
|
+
* a Files payload worth re-shaping) while this function passes through
|
|
214
|
+
* (`no-entry-api` — an intake caller that cannot even enumerate its items
|
|
215
|
+
* must not be intercepted blind). If either function's gate sequence or
|
|
216
|
+
* drop-entry rule changes, update the other in the same commit; the truth
|
|
217
|
+
* tables in scripts/check-remote-drop.mjs pin both sides.
|
|
218
|
+
*/
|
|
219
|
+
export function decideRemoteIntake(input: RemoteIntakeInput): RemoteIntakeDecision {
|
|
220
|
+
if (!input.trusted) return { action: 'passthrough', reason: 'untrusted-event' }
|
|
221
|
+
if (!input.hasHostPaths) return { action: 'passthrough', reason: 'no-host-paths' }
|
|
222
|
+
if (!isRemoteSessionId(input.currentSessionId)) return { action: 'passthrough', reason: 'not-remote-session' }
|
|
223
|
+
if (input.items === undefined) return { action: 'passthrough', reason: 'no-entry-api' }
|
|
224
|
+
if (input.items.some((item) => item.isDirectory)) return { action: 'block-directories', reason: 'contains-directory' }
|
|
225
|
+
if (input.items.length === 0) return { action: 'passthrough', reason: 'no-files' }
|
|
226
|
+
const intervenes = input.items.some((item) => item.entry === 'drop' || (item.hasPath && !item.isImage))
|
|
227
|
+
return intervenes
|
|
228
|
+
? { action: 'redirect', reason: 'remote-path-files' }
|
|
229
|
+
: { action: 'passthrough', reason: 'no-path-or-image-only' }
|
|
230
|
+
}
|
|
@@ -97,6 +97,9 @@ export const CLIENT_CLAIM_ROUTE = '/_dsh/zen-remote/client/claim'
|
|
|
97
97
|
export const CLIENT_STATUS_ROUTE = '/_dsh/zen-remote/client/status'
|
|
98
98
|
/** T43: one immediate reconnect — answered 409 unless the client is offline. */
|
|
99
99
|
export const CLIENT_RECONNECT_ROUTE = '/_dsh/zen-remote/client/reconnect'
|
|
100
|
+
/** CU-4: the redacted diagnostic report (GET, both roles, machine-local only
|
|
101
|
+
* — a gateway-forwarded fetch is refused server-side). */
|
|
102
|
+
export const CLIENT_DEBUG_REPORT_ROUTE = '/_dsh/zen-remote/client/debug-report'
|
|
100
103
|
|
|
101
104
|
/** The lightweight client-facing config route (host half, both roles): the
|
|
102
105
|
* settings page's FALLBACK role probe (T17) — it registers wherever a
|
package/src/client-pairing.ts
CHANGED
|
@@ -39,6 +39,17 @@ function isHttpAllowedHost(hostname: string): boolean {
|
|
|
39
39
|
return isAllowedIpv4(hostname)
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Whether one hostname sits in a private/loopback network — the same verdict
|
|
44
|
+
* {@link isHttpAllowedHost} applies inside `normalizeServerUrl`, exported for
|
|
45
|
+
* the debug report's serverUrl shaper (CU-2): the one place outside the
|
|
46
|
+
* normalizer that needs the private-network fact, kept here so the address
|
|
47
|
+
* classes can never drift apart from what pairing actually accepts.
|
|
48
|
+
*/
|
|
49
|
+
export function isPrivateNetworkHost(hostname: string): boolean {
|
|
50
|
+
return isHttpAllowedHost(hostname)
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
/** Dotted-quad check against the allowed ranges. The hostname comes from a
|
|
43
54
|
* parsed URL, so it is already canonical (the WHATWG parser folds exotic
|
|
44
55
|
* spellings like `010.0x0A.0.3` into plain decimal before we ever see it). */
|