dsh-zen-remote 2.3.6 → 2.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -5
- package/CHANGELOG.md +83 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/docs/interface.md +10 -4
- package/docs/remote-access.en.md +6 -0
- package/docs/remote-access.md +6 -0
- package/lan-gate.mjs +7 -0
- package/lib/client-pairing.js +10 -0
- package/lib/client-pairing.js.map +1 -1
- package/lib/client-routes.js +183 -94
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +505 -78
- package/lib/debug-redact.js +253 -0
- package/lib/debug-redact.js.map +1 -0
- package/lib/debug-report.js +60 -0
- package/lib/debug-report.js.map +1 -0
- package/lib/fetch-route-intercept.js +23 -0
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +293 -45
- package/lib/index.js.map +1 -1
- package/lib/log-ring.js +76 -0
- package/lib/log-ring.js.map +1 -0
- package/lib/merge-streams.js +55 -19
- package/lib/merge-streams.js.map +1 -1
- package/lib/relay-filter.js +4 -3
- package/lib/relay-filter.js.map +1 -1
- package/lib/relay-server.js +5 -3
- package/lib/relay-server.js.map +1 -1
- package/lib/types/client/effects/remote-drop.d.ts +20 -8
- package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +8 -1
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/misc.css.d.ts +1 -1
- package/lib/types/client/styles/misc.css.d.ts.map +1 -1
- package/lib/types/client-data/remote-drop.d.ts +64 -0
- package/lib/types/client-data/remote-drop.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +3 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-pairing.d.ts +8 -0
- package/lib/types/client-pairing.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +23 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/debug-redact.d.ts +68 -0
- package/lib/types/debug-redact.d.ts.map +1 -0
- package/lib/types/debug-report.d.ts +58 -0
- package/lib/types/debug-report.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +26 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +7 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/log-ring.d.ts +57 -0
- package/lib/types/log-ring.d.ts.map +1 -0
- package/lib/types/merge-streams.d.ts +27 -0
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/relay-filter.d.ts +4 -3
- package/lib/types/relay-filter.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts.map +1 -1
- package/package.json +6 -3
- package/pwa/app.css +13 -2
- package/scripts/check-debug-report.mjs +277 -0
- package/scripts/check-remote-drop.mjs +130 -0
- package/scripts/check-tablet-safe-area.mjs +49 -0
- package/src/client/effects/remote-drop.ts +271 -24
- package/src/client/locales.ts +20 -4
- package/src/client/settings/SettingsSection.tsx +120 -3
- package/src/client/styles/misc.css.ts +38 -0
- package/src/client-data/remote-drop.ts +103 -0
- package/src/client-data/settings-form.ts +3 -0
- package/src/client-pairing.ts +11 -0
- package/src/client-routes.ts +196 -90
- package/src/debug-redact.ts +290 -0
- package/src/debug-report.ts +93 -0
- package/src/fetch-route-intercept.ts +29 -0
- package/src/index.ts +282 -43
- package/src/log-ring.ts +101 -0
- package/src/merge-streams.ts +71 -16
- package/src/relay-filter.ts +4 -3
- package/src/relay-server.ts +5 -3
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import type { ClientContext } from '../compat/types.ts'
|
|
2
|
-
import { decideRemoteDrop, rewrapFiles } from '../../client-data/remote-drop.ts'
|
|
3
|
-
import type { RemoteDropItemDescriptor } from '../../client-data/remote-drop.ts'
|
|
2
|
+
import { decideRemoteDrop, decideRemoteIntake, isRemoteSessionId, rewrapFiles } from '../../client-data/remote-drop.ts'
|
|
3
|
+
import type { RemoteDropItemDescriptor, RemoteIntakeDescriptor } from '../../client-data/remote-drop.ts'
|
|
4
4
|
import { CURRENT_SESSION_STORAGE_KEY, parseCurrentSessionStorage } from '../../client-data/current-session.ts'
|
|
5
5
|
import { en, zh } from '../locales.ts'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* The remote-
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* The remote attachment-entry guard (RD-2 + AU-2): document-level CAPTURE-phase
|
|
9
|
+
* listeners on the three entries a desktop file reaches the composer through —
|
|
10
|
+
* the `drop` event, the composer's hidden picker (`change` on the unlabelled
|
|
11
|
+
* `<input type=file multiple hidden>`), and the editor's `paste` — that
|
|
12
|
+
* re-shape each intake landing in a remote session before the host's own
|
|
13
|
+
* handlers see it.
|
|
11
14
|
*
|
|
12
15
|
* The desktop app's zero-copy fast path is the problem: a File dragged from
|
|
13
16
|
* the OS keeps its disk path, the host turns it into an `@/absolute/path`
|
|
@@ -22,14 +25,22 @@ import { en, zh } from '../locales.ts'
|
|
|
22
25
|
* cleanup, busy rejection, directory detection and the addFiles branch all
|
|
23
26
|
* stay the host's own code; nothing downstream is reimplemented here.
|
|
24
27
|
*
|
|
25
|
-
* Gates (all three must pass, decided by RD-1's pure
|
|
28
|
+
* Gates (all three must pass, decided by RD-1's pure functions): the event
|
|
26
29
|
* is trusted (our redispatch is untrusted — that alone makes the guard
|
|
27
30
|
* loop-free), the Electron bridge exists (phone / plain-browser hosts are
|
|
28
|
-
* already on the upload branch
|
|
31
|
+
* already on the upload branch — the mount's capability check declines, so
|
|
32
|
+
* no listener exists at all there), and the current session (`dsh.sessions.
|
|
29
33
|
* current`, read fresh at event time) is a `zr~` virtual id. A missing
|
|
30
34
|
* signal counts as a local session — the conservative direction: the only
|
|
31
|
-
* misfire a stale signal can cause is uploading bytes for a local
|
|
32
|
-
* never losing one.
|
|
35
|
+
* misfire a stale signal can cause is uploading bytes for a local intake,
|
|
36
|
+
* never losing one. The two NEW entries (picker / paste) additionally
|
|
37
|
+
* intervene only on the zero-copy bad branch — a file that CARRIES a disk
|
|
38
|
+
* path and is NOT an image; pathless and image files already upload bytes
|
|
39
|
+
* and pass through untouched (issue #49: only that branch breaks remotely).
|
|
40
|
+
* Counting is per-entry too: the two NEW entries check the remote gate
|
|
41
|
+
* BEFORE counting, so everyday local picker/paste traffic never shows up in
|
|
42
|
+
* the diagnostics; the drop entry keeps its 2.2.6 behavior, where a local
|
|
43
|
+
* file drop still records one passthrough.
|
|
33
44
|
*
|
|
34
45
|
* Degrades to not-installed when DragEvent / DataTransfer construction is
|
|
35
46
|
* unavailable — back to exactly today's behavior (RD-4's interface.md
|
|
@@ -41,26 +52,41 @@ interface StorageLike {
|
|
|
41
52
|
getItem(key: string): string | null
|
|
42
53
|
}
|
|
43
54
|
|
|
55
|
+
/** Which entry a counted event came through. */
|
|
56
|
+
type IntakeEntry = 'drop' | 'picker' | 'paste'
|
|
57
|
+
|
|
58
|
+
export interface RemoteDropEntryCounts {
|
|
59
|
+
drop: number
|
|
60
|
+
picker: number
|
|
61
|
+
paste: number
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
export interface RemoteDropDiagnostics {
|
|
45
|
-
/** The
|
|
65
|
+
/** The listeners are mounted and armed (capability check passed, effect
|
|
46
66
|
* not yet disposed). */
|
|
47
67
|
installed: boolean
|
|
48
68
|
/** Why the capability check declined to install; `undefined` while
|
|
49
69
|
* installed (and after a dispose, which keeps the counters readable). */
|
|
50
70
|
notInstalledReason: string | undefined
|
|
51
|
-
/**
|
|
52
|
-
* the
|
|
71
|
+
/** Intakes this guard stopped from reaching the host (both the blocked and
|
|
72
|
+
* the redirected kind) — the sum of the three entries below. */
|
|
53
73
|
intercepted: number
|
|
54
|
-
/** Synthetic path-stripped drops actually dispatched. */
|
|
74
|
+
/** Synthetic path-stripped drops actually dispatched (= redirected sum). */
|
|
55
75
|
redispatched: number
|
|
56
|
-
/**
|
|
57
|
-
* dispatched). */
|
|
76
|
+
/** Intakes stopped because they carried a folder (toast shown, nothing
|
|
77
|
+
* dispatched) (= blocked sum). */
|
|
58
78
|
blockedDirectories: number
|
|
59
|
-
/**
|
|
79
|
+
/** Intakes judged not-ours and released untouched (= passthrough sum). */
|
|
60
80
|
passthrough: number
|
|
61
81
|
/** Redispatches aborted because a re-wrap or a DataTransfer add threw —
|
|
62
82
|
* the original event was released instead (宁坏勿丢). */
|
|
63
83
|
rewrapFailed: number
|
|
84
|
+
/** The same four counters split by entry (AU-2); the scalars above are
|
|
85
|
+
* their sums, so existing readers keep working unchanged. */
|
|
86
|
+
interceptedBy: RemoteDropEntryCounts
|
|
87
|
+
redirectedBy: RemoteDropEntryCounts
|
|
88
|
+
blockedBy: RemoteDropEntryCounts
|
|
89
|
+
passthroughBy: RemoteDropEntryCounts
|
|
64
90
|
}
|
|
65
91
|
|
|
66
92
|
/** Module-level singleton: the bundle has one document, so one guard state.
|
|
@@ -68,13 +94,36 @@ export interface RemoteDropDiagnostics {
|
|
|
68
94
|
let diagnostics: RemoteDropDiagnostics = freshDiagnostics()
|
|
69
95
|
|
|
70
96
|
function freshDiagnostics(): RemoteDropDiagnostics {
|
|
71
|
-
return {
|
|
97
|
+
return {
|
|
98
|
+
installed: false,
|
|
99
|
+
notInstalledReason: undefined,
|
|
100
|
+
intercepted: 0,
|
|
101
|
+
redispatched: 0,
|
|
102
|
+
blockedDirectories: 0,
|
|
103
|
+
passthrough: 0,
|
|
104
|
+
rewrapFailed: 0,
|
|
105
|
+
interceptedBy: { drop: 0, picker: 0, paste: 0 },
|
|
106
|
+
redirectedBy: { drop: 0, picker: 0, paste: 0 },
|
|
107
|
+
blockedBy: { drop: 0, picker: 0, paste: 0 },
|
|
108
|
+
passthroughBy: { drop: 0, picker: 0, paste: 0 },
|
|
109
|
+
}
|
|
72
110
|
}
|
|
73
111
|
|
|
74
112
|
/** A snapshot for the diagnostics carriers (settings page). Copying keeps
|
|
75
|
-
* callers from holding a live reference into a later mount's state
|
|
113
|
+
* callers from holding a live reference into a later mount's state — the
|
|
114
|
+
* per-entry objects are copied too, or a caller would keep counting. */
|
|
76
115
|
export function remoteDropDiagnostics(): RemoteDropDiagnostics {
|
|
77
|
-
return {
|
|
116
|
+
return {
|
|
117
|
+
...diagnostics,
|
|
118
|
+
interceptedBy: { ...diagnostics.interceptedBy },
|
|
119
|
+
redirectedBy: { ...diagnostics.redirectedBy },
|
|
120
|
+
blockedBy: { ...diagnostics.blockedBy },
|
|
121
|
+
passthroughBy: { ...diagnostics.passthroughBy },
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function bump(counts: RemoteDropEntryCounts, entry: IntakeEntry): void {
|
|
126
|
+
counts[entry] += 1
|
|
78
127
|
}
|
|
79
128
|
|
|
80
129
|
/** The contextBridge global's existence only — never touched otherwise
|
|
@@ -83,6 +132,24 @@ function hostPathsOf(): unknown {
|
|
|
83
132
|
return (globalThis as { __DSH_HOST_PATHS__?: unknown }).__DSH_HOST_PATHS__
|
|
84
133
|
}
|
|
85
134
|
|
|
135
|
+
/** The bridge's `pathFor` — the preload's `webUtils.getPathForFile`. A
|
|
136
|
+
* non-empty string means the File still carries its disk path, the exact
|
|
137
|
+
* fact the host's zero-copy branch keys on. Anything else (bridge shape,
|
|
138
|
+
* non-string answer, a throw) reads as "no path" — the conservative
|
|
139
|
+
* direction: the file is treated as already byte-uploading and stays
|
|
140
|
+
* untouched. */
|
|
141
|
+
function bridgePathFor(bridge: unknown, file: File): string {
|
|
142
|
+
if (bridge === null || typeof bridge !== 'object') return ''
|
|
143
|
+
const pathFor = (bridge as { pathFor?: unknown }).pathFor
|
|
144
|
+
if (typeof pathFor !== 'function') return ''
|
|
145
|
+
try {
|
|
146
|
+
const result = (pathFor as (file: File) => unknown).call(bridge, file)
|
|
147
|
+
return typeof result === 'string' ? result : ''
|
|
148
|
+
} catch {
|
|
149
|
+
return ''
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
86
153
|
/** The current-session signal, read AT EVENT TIME (the selection changes
|
|
87
154
|
* while the page is open; a mount-time snapshot would go stale). Any
|
|
88
155
|
* storage failure reads as "no signal" = local session = passthrough. */
|
|
@@ -141,6 +208,28 @@ function dismissHostDropOverlay(): void {
|
|
|
141
208
|
}
|
|
142
209
|
}
|
|
143
210
|
|
|
211
|
+
/** Build the redispatch payload WITHOUT touching any event: re-wrap the files
|
|
212
|
+
* (path-stripped) and pack them into a fresh DataTransfer. `undefined` means
|
|
213
|
+
* the file list was empty (nothing to dispatch — the caller passes through);
|
|
214
|
+
* a throw means a re-wrap or an items.add failed (0-byte / exotic File) and
|
|
215
|
+
* the caller releases the original event untouched (宁坏勿丢). Shared by all
|
|
216
|
+
* three entries — the payload shape is the redispatch contract, and the
|
|
217
|
+
* drop entry's behavior is frozen by its existing tests. */
|
|
218
|
+
function buildDropPayload(files: readonly File[]): DataTransfer | undefined {
|
|
219
|
+
const rewrapped = rewrapFiles(files)
|
|
220
|
+
if (rewrapped.length === 0) return undefined
|
|
221
|
+
const payload = new DataTransfer()
|
|
222
|
+
for (const file of rewrapped) payload.items.add(file)
|
|
223
|
+
return payload
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Dispatch the host-shaped synthetic drop on document, bubble phase — the
|
|
227
|
+
* host's own drop handler (mask reset, busy rejection, addFiles) digests it;
|
|
228
|
+
* our capture listener skips it via the isTrusted gate. */
|
|
229
|
+
function dispatchSyntheticDrop(payload: DataTransfer): void {
|
|
230
|
+
document.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: payload }))
|
|
231
|
+
}
|
|
232
|
+
|
|
144
233
|
export function mountRemoteDropGuard(ctx: ClientContext): void {
|
|
145
234
|
ctx.effect(() => {
|
|
146
235
|
// Fresh state per mount: a plugin reload re-runs apply() and must not
|
|
@@ -216,6 +305,7 @@ export function mountRemoteDropGuard(ctx: ClientContext): void {
|
|
|
216
305
|
})
|
|
217
306
|
if (decision.action === 'passthrough') {
|
|
218
307
|
diagnostics.passthrough += 1
|
|
308
|
+
bump(diagnostics.passthroughBy, 'drop')
|
|
219
309
|
return
|
|
220
310
|
}
|
|
221
311
|
if (decision.action === 'block-directories') {
|
|
@@ -225,7 +315,9 @@ export function mountRemoteDropGuard(ctx: ClientContext): void {
|
|
|
225
315
|
event.stopPropagation()
|
|
226
316
|
dismissHostDropOverlay()
|
|
227
317
|
diagnostics.intercepted += 1
|
|
318
|
+
bump(diagnostics.interceptedBy, 'drop')
|
|
228
319
|
diagnostics.blockedDirectories += 1
|
|
320
|
+
bump(diagnostics.blockedBy, 'drop')
|
|
229
321
|
showToast()
|
|
230
322
|
return
|
|
231
323
|
}
|
|
@@ -234,22 +326,23 @@ export function mountRemoteDropGuard(ctx: ClientContext): void {
|
|
|
234
326
|
// host still receives today's (path-chip) behavior instead of
|
|
235
327
|
// nothing at all.
|
|
236
328
|
try {
|
|
237
|
-
const
|
|
238
|
-
if (
|
|
329
|
+
const payload = buildDropPayload(Array.from(transfer.files))
|
|
330
|
+
if (payload === undefined) {
|
|
239
331
|
diagnostics.passthrough += 1
|
|
332
|
+
bump(diagnostics.passthroughBy, 'drop')
|
|
240
333
|
return
|
|
241
334
|
}
|
|
242
|
-
const payload = new DataTransfer()
|
|
243
|
-
for (const file of rewrapped) payload.items.add(file)
|
|
244
335
|
event.preventDefault()
|
|
245
336
|
event.stopPropagation()
|
|
246
337
|
diagnostics.intercepted += 1
|
|
338
|
+
bump(diagnostics.interceptedBy, 'drop')
|
|
247
339
|
// The host's drop handler sits on document, bubble phase (F4): a
|
|
248
340
|
// bubbling synthetic drop on document reaches it. Its own handler
|
|
249
341
|
// does the preventDefault / mask reset / addFiles work; our capture
|
|
250
342
|
// listener skips this event via the isTrusted gate above.
|
|
251
|
-
|
|
343
|
+
dispatchSyntheticDrop(payload)
|
|
252
344
|
diagnostics.redispatched += 1
|
|
345
|
+
bump(diagnostics.redirectedBy, 'drop')
|
|
253
346
|
} catch {
|
|
254
347
|
// A re-wrap or a single items.add threw (0-byte / exotic File):
|
|
255
348
|
// count it and let the original event flow — 宁坏勿丢.
|
|
@@ -258,8 +351,162 @@ export function mountRemoteDropGuard(ctx: ClientContext): void {
|
|
|
258
351
|
}
|
|
259
352
|
document.addEventListener('drop', onDrop, true)
|
|
260
353
|
|
|
354
|
+
// --- the picker entry (AU-2): the composer's hidden file selector -------
|
|
355
|
+
// F1: the host composer's picker is an `input[type=file][multiple][hidden]`
|
|
356
|
+
// with NO accept attribute, and its React onChange feeds intakeFiles →
|
|
357
|
+
// addFiles — the same zero-copy branch the drop entry guards. Judged by
|
|
358
|
+
// STRUCTURE, never by class names (hash classes cannot be anchored): an
|
|
359
|
+
// input with an accept attribute (import / restore pickers), a visible
|
|
360
|
+
// one, or a `webkitdirectory` folder picker (a third-party composition
|
|
361
|
+
// could combine all four) is somebody else's and passes through. The
|
|
362
|
+
// host composer has none of those, so the hit is unchanged.
|
|
363
|
+
const onChange = (event: Event): void => {
|
|
364
|
+
if (!event.isTrusted) return
|
|
365
|
+
const input = event.target
|
|
366
|
+
if (!(input instanceof HTMLInputElement)) return
|
|
367
|
+
if (input.type !== 'file' || !input.multiple || !input.hidden || input.hasAttribute('accept') || input.webkitdirectory) return
|
|
368
|
+
const files = Array.from(input.files ?? [])
|
|
369
|
+
if (files.length === 0) return
|
|
370
|
+
// The remote gate first, BEFORE counting: a local session's everyday
|
|
371
|
+
// picker use must not show up in the diagnostics at all (the counters
|
|
372
|
+
// are a record of what the guard saw in remote sessions).
|
|
373
|
+
if (!isRemoteSessionId(readCurrentSessionId())) return
|
|
374
|
+
const bridge = hostPathsOf()
|
|
375
|
+
const items: RemoteIntakeDescriptor[] = files.map((file) => ({
|
|
376
|
+
entry: 'picker',
|
|
377
|
+
// A picker yields plain files; directories are structurally
|
|
378
|
+
// impossible, so the block-directories branch is unreachable here
|
|
379
|
+
// (defensive passthrough below).
|
|
380
|
+
isDirectory: false,
|
|
381
|
+
hasPath: bridgePathFor(bridge, file) !== '',
|
|
382
|
+
isImage: file.type.startsWith('image/'),
|
|
383
|
+
}))
|
|
384
|
+
const decision = decideRemoteIntake({
|
|
385
|
+
hasHostPaths: bridge !== undefined,
|
|
386
|
+
currentSessionId: readCurrentSessionId(),
|
|
387
|
+
trusted: event.isTrusted,
|
|
388
|
+
items,
|
|
389
|
+
})
|
|
390
|
+
if (decision.action !== 'redirect') {
|
|
391
|
+
// passthrough, or the defensive block branch that cannot happen for
|
|
392
|
+
// a picker — either way zero intervention.
|
|
393
|
+
diagnostics.passthrough += 1
|
|
394
|
+
bump(diagnostics.passthroughBy, 'picker')
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
// Build the payload BEFORE stopping the event (宁坏勿丢): a re-wrap
|
|
398
|
+
// failure releases the change untouched — the host keeps today's
|
|
399
|
+
// (path-chip) behavior instead of losing the pick entirely.
|
|
400
|
+
try {
|
|
401
|
+
const payload = buildDropPayload(files)
|
|
402
|
+
if (payload === undefined) {
|
|
403
|
+
diagnostics.passthrough += 1
|
|
404
|
+
bump(diagnostics.passthroughBy, 'picker')
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
// stopPropagation keeps the event from reaching React's delegated
|
|
408
|
+
// onChange at the root container (F5); clearing the value lets the
|
|
409
|
+
// SAME file trigger a later change again (the host's own onPickFiles
|
|
410
|
+
// habit, applied here since the host's handler no longer runs).
|
|
411
|
+
event.stopPropagation()
|
|
412
|
+
input.value = ''
|
|
413
|
+
diagnostics.intercepted += 1
|
|
414
|
+
bump(diagnostics.interceptedBy, 'picker')
|
|
415
|
+
dispatchSyntheticDrop(payload)
|
|
416
|
+
diagnostics.redispatched += 1
|
|
417
|
+
bump(diagnostics.redirectedBy, 'picker')
|
|
418
|
+
} catch {
|
|
419
|
+
diagnostics.rewrapFailed += 1
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
document.addEventListener('change', onChange, true)
|
|
423
|
+
|
|
424
|
+
// --- the paste entry (AU-2): the editor's clipboard files ---------------
|
|
425
|
+
// F2: the composer editor (a contenteditable root) reads file items off
|
|
426
|
+
// clipboardData; a path-carrying non-image lands on the same zero-copy
|
|
427
|
+
// branch. Pasting into any other target (settings fields, search) is not
|
|
428
|
+
// ours. clipboardData is read-only, so a redirect can only re-dispatch
|
|
429
|
+
// — preventDefault keeps the editor from inserting anything.
|
|
430
|
+
const onPaste = (event: ClipboardEvent): void => {
|
|
431
|
+
if (!event.isTrusted) return
|
|
432
|
+
const transfer = event.clipboardData
|
|
433
|
+
if (transfer === null) return
|
|
434
|
+
const firstElement = event.composedPath().find((node): node is Element => node instanceof Element)
|
|
435
|
+
if (firstElement === undefined || firstElement.closest('[contenteditable="true"]') === null) return
|
|
436
|
+
// The remote gate BEFORE any per-item work (same rationale as picker):
|
|
437
|
+
// a local session's paste — including pasted FILES — returns here, so
|
|
438
|
+
// it never runs bridgePathFor per file and never reaches the counters.
|
|
439
|
+
// Everything after this point still runs inside this one synchronous
|
|
440
|
+
// handler, within the dispatch lifetime clipboardData requires.
|
|
441
|
+
if (!isRemoteSessionId(readCurrentSessionId())) return
|
|
442
|
+
const bridge = hostPathsOf()
|
|
443
|
+
const files: File[] = []
|
|
444
|
+
const items: RemoteIntakeDescriptor[] = []
|
|
445
|
+
for (const item of Array.from(transfer.items)) {
|
|
446
|
+
if (item.kind !== 'file') continue
|
|
447
|
+
const file = item.getAsFile()
|
|
448
|
+
if (file === null) continue
|
|
449
|
+
// Directory detection with the host's own default: no entry API
|
|
450
|
+
// reads as "not a directory". Items without a File behind them never
|
|
451
|
+
// reach the descriptors, so a plain-text paste arrives as an empty
|
|
452
|
+
// list — the pure no-files passthrough.
|
|
453
|
+
const entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null
|
|
454
|
+
files.push(file)
|
|
455
|
+
items.push({
|
|
456
|
+
entry: 'paste',
|
|
457
|
+
isDirectory: entry?.isDirectory === true,
|
|
458
|
+
hasPath: bridgePathFor(bridge, file) !== '',
|
|
459
|
+
isImage: file.type.startsWith('image/'),
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
const decision = decideRemoteIntake({
|
|
463
|
+
hasHostPaths: bridge !== undefined,
|
|
464
|
+
currentSessionId: readCurrentSessionId(),
|
|
465
|
+
trusted: event.isTrusted,
|
|
466
|
+
items,
|
|
467
|
+
})
|
|
468
|
+
if (decision.action === 'block-directories') {
|
|
469
|
+
// A folder paste cannot be fixed by single-file upload: same toast
|
|
470
|
+
// semantics as the drop branch (no overlay to dismiss — paste never
|
|
471
|
+
// opened one).
|
|
472
|
+
event.preventDefault()
|
|
473
|
+
event.stopPropagation()
|
|
474
|
+
diagnostics.intercepted += 1
|
|
475
|
+
bump(diagnostics.interceptedBy, 'paste')
|
|
476
|
+
diagnostics.blockedDirectories += 1
|
|
477
|
+
bump(diagnostics.blockedBy, 'paste')
|
|
478
|
+
showToast()
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
if (decision.action !== 'redirect') {
|
|
482
|
+
diagnostics.passthrough += 1
|
|
483
|
+
bump(diagnostics.passthroughBy, 'paste')
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
const payload = buildDropPayload(files)
|
|
488
|
+
if (payload === undefined) {
|
|
489
|
+
diagnostics.passthrough += 1
|
|
490
|
+
bump(diagnostics.passthroughBy, 'paste')
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
event.preventDefault()
|
|
494
|
+
event.stopPropagation()
|
|
495
|
+
diagnostics.intercepted += 1
|
|
496
|
+
bump(diagnostics.interceptedBy, 'paste')
|
|
497
|
+
dispatchSyntheticDrop(payload)
|
|
498
|
+
diagnostics.redispatched += 1
|
|
499
|
+
bump(diagnostics.redirectedBy, 'paste')
|
|
500
|
+
} catch {
|
|
501
|
+
diagnostics.rewrapFailed += 1
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
document.addEventListener('paste', onPaste, true)
|
|
505
|
+
|
|
261
506
|
return () => {
|
|
262
507
|
document.removeEventListener('drop', onDrop, true)
|
|
508
|
+
document.removeEventListener('change', onChange, true)
|
|
509
|
+
document.removeEventListener('paste', onPaste, true)
|
|
263
510
|
removeToast()
|
|
264
511
|
// Keep the counters readable after a dispose (the settings page may
|
|
265
512
|
// still be open mid-reload); `installed` is the armed flag only.
|
package/src/client/locales.ts
CHANGED
|
@@ -255,15 +255,23 @@ export const zh = {
|
|
|
255
255
|
'settings.client.diagRecentFailures': '最近的远程调用失败',
|
|
256
256
|
'settings.client.diagCompatGroups': '指纹有差异的组:{groups}',
|
|
257
257
|
'settings.client.diagCompatCalls': '最近的不兼容调用',
|
|
258
|
-
// --- RD-3
|
|
259
|
-
'settings.client.diagRemoteDrop': '
|
|
258
|
+
// --- RD-3/AU-3 附件入口守卫的诊断行(浏览器端计数,渲染时现读) ---
|
|
259
|
+
'settings.client.diagRemoteDrop': '附件入口守卫',
|
|
260
260
|
'settings.client.diagRemoteDropOk': '已安装 · 拦截 {intercepted} · 重派发 {redispatched} · 文件夹 {blocked} · 放行 {passthrough} · 重包失败 {failed}',
|
|
261
|
+
'settings.client.diagRemoteDropEntries': '分入口 · 拦截 拖拽 {dropI} / 回形针 {pickerI} / 粘贴 {pasteI} · 放行 拖拽 {dropP} / 回形针 {pickerP} / 粘贴 {pasteP}',
|
|
261
262
|
'settings.client.diagRemoteDropOff': '未安装({reason})',
|
|
262
263
|
// --- RD-6 上传改道(fetch-route 拦截)的诊断行:读 remote-status 的 serviceProbe ---
|
|
263
264
|
'settings.client.diagUploadRedirect': '上传改道',
|
|
264
265
|
'settings.client.diagUploadRedirectOk': '已安装 · 上传已接管',
|
|
265
266
|
'settings.client.diagUploadRedirectRefused': '拒装:{reason}',
|
|
266
267
|
'settings.client.diagUploadRedirectPending': '服务注入未触发 · connection {connection} · fileUploads {fileUploads}',
|
|
268
|
+
// --- CU-4 诊断信息导出(设置页最底部,两种角色都有) ---
|
|
269
|
+
'settings.debug.title': '诊断信息',
|
|
270
|
+
'settings.debug.export': '导出诊断信息',
|
|
271
|
+
'settings.debug.exporting': '正在生成…',
|
|
272
|
+
'settings.debug.hint': '下载一份脱敏后的诊断文件(版本、配置形态、连接诊断、插件日志)。令牌与密钥、对话内容、附件和完整文件路径一律不包含,服务器地址只保留类型。建议从机与主服务器各导一份,可直接附到 GitHub issue。',
|
|
273
|
+
'settings.debug.done': '诊断文件已下载',
|
|
274
|
+
'settings.debug.fail': '导出失败:无法生成诊断报告',
|
|
267
275
|
// --- PB-5 插件桥:主服务端同意小节 + 子客户端诊断小节 ---
|
|
268
276
|
'settings.bridge.title': '插件桥',
|
|
269
277
|
'settings.bridge.empty': '暂无插件登记插件桥',
|
|
@@ -766,16 +774,24 @@ export const en: Record<MobileNavKey, string> = {
|
|
|
766
774
|
'settings.client.diagRecentFailures': 'Recent remote call failures',
|
|
767
775
|
'settings.client.diagCompatGroups': 'Groups with fingerprint differences: {groups}',
|
|
768
776
|
'settings.client.diagCompatCalls': 'Recent incompatible calls',
|
|
769
|
-
// --- RD-3: the
|
|
777
|
+
// --- RD-3/AU-3: the attachment-entry guard's diagnostics line (browser-side
|
|
770
778
|
// counters, read at render time) ---
|
|
771
|
-
'settings.client.diagRemoteDrop': '
|
|
779
|
+
'settings.client.diagRemoteDrop': 'Attachment entry guard',
|
|
772
780
|
'settings.client.diagRemoteDropOk': 'Installed · intercepted {intercepted} · redispatched {redispatched} · folders {blocked} · passthrough {passthrough} · rewrap failed {failed}',
|
|
781
|
+
'settings.client.diagRemoteDropEntries': 'By entry · intercepted drop {dropI} / picker {pickerI} / paste {pasteI} · passthrough drop {dropP} / picker {pickerP} / paste {pasteP}',
|
|
773
782
|
'settings.client.diagRemoteDropOff': 'Not installed ({reason})',
|
|
774
783
|
// --- RD-6: the upload-redirect (fetch-route intercept) diagnostics row ---
|
|
775
784
|
'settings.client.diagUploadRedirect': 'Upload redirect',
|
|
776
785
|
'settings.client.diagUploadRedirectOk': 'Installed · upload wrapped',
|
|
777
786
|
'settings.client.diagUploadRedirectRefused': 'Refused: {reason}',
|
|
778
787
|
'settings.client.diagUploadRedirectPending': 'Service inject never fired · connection {connection} · fileUploads {fileUploads}',
|
|
788
|
+
// --- CU-4: the diagnostics export (bottom of the settings page, both roles) ---
|
|
789
|
+
'settings.debug.title': 'Diagnostics',
|
|
790
|
+
'settings.debug.export': 'Export diagnostics',
|
|
791
|
+
'settings.debug.exporting': 'Generating…',
|
|
792
|
+
'settings.debug.hint': 'Download a redacted diagnostics file (versions, config shape, connection diagnostics, plugin log). Tokens and secrets, conversation content, attachments and full file paths are never included; server addresses keep only their type. Export one on the client and one on the server — both can be attached to a GitHub issue as-is.',
|
|
793
|
+
'settings.debug.done': 'Diagnostics file downloaded',
|
|
794
|
+
'settings.debug.fail': 'Export failed: the diagnostics report could not be generated',
|
|
779
795
|
// --- PB-5 plugin bridge: host consent section + sub-client diagnostics ---
|
|
780
796
|
'settings.bridge.title': 'Plugin bridge',
|
|
781
797
|
'settings.bridge.empty': 'No plugin has registered a bridge yet',
|
|
@@ -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
|
}
|