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.
Files changed (80) hide show
  1. package/AGENTS.md +15 -5
  2. package/CHANGELOG.md +83 -0
  3. package/README.md +2 -2
  4. package/README.zh-CN.md +2 -2
  5. package/docs/interface.md +10 -4
  6. package/docs/remote-access.en.md +6 -0
  7. package/docs/remote-access.md +6 -0
  8. package/lan-gate.mjs +7 -0
  9. package/lib/client-pairing.js +10 -0
  10. package/lib/client-pairing.js.map +1 -1
  11. package/lib/client-routes.js +183 -94
  12. package/lib/client-routes.js.map +1 -1
  13. package/lib/client.js +505 -78
  14. package/lib/debug-redact.js +253 -0
  15. package/lib/debug-redact.js.map +1 -0
  16. package/lib/debug-report.js +60 -0
  17. package/lib/debug-report.js.map +1 -0
  18. package/lib/fetch-route-intercept.js +23 -0
  19. package/lib/fetch-route-intercept.js.map +1 -1
  20. package/lib/index.js +293 -45
  21. package/lib/index.js.map +1 -1
  22. package/lib/log-ring.js +76 -0
  23. package/lib/log-ring.js.map +1 -0
  24. package/lib/merge-streams.js +55 -19
  25. package/lib/merge-streams.js.map +1 -1
  26. package/lib/relay-filter.js +4 -3
  27. package/lib/relay-filter.js.map +1 -1
  28. package/lib/relay-server.js +5 -3
  29. package/lib/relay-server.js.map +1 -1
  30. package/lib/types/client/effects/remote-drop.d.ts +20 -8
  31. package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
  32. package/lib/types/client/locales.d.ts +8 -1
  33. package/lib/types/client/locales.d.ts.map +1 -1
  34. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  35. package/lib/types/client/styles/misc.css.d.ts +1 -1
  36. package/lib/types/client/styles/misc.css.d.ts.map +1 -1
  37. package/lib/types/client-data/remote-drop.d.ts +64 -0
  38. package/lib/types/client-data/remote-drop.d.ts.map +1 -1
  39. package/lib/types/client-data/settings-form.d.ts +3 -0
  40. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  41. package/lib/types/client-pairing.d.ts +8 -0
  42. package/lib/types/client-pairing.d.ts.map +1 -1
  43. package/lib/types/client-routes.d.ts +23 -0
  44. package/lib/types/client-routes.d.ts.map +1 -1
  45. package/lib/types/debug-redact.d.ts +68 -0
  46. package/lib/types/debug-redact.d.ts.map +1 -0
  47. package/lib/types/debug-report.d.ts +58 -0
  48. package/lib/types/debug-report.d.ts.map +1 -0
  49. package/lib/types/fetch-route-intercept.d.ts +26 -0
  50. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  51. package/lib/types/index.d.ts +7 -1
  52. package/lib/types/index.d.ts.map +1 -1
  53. package/lib/types/log-ring.d.ts +57 -0
  54. package/lib/types/log-ring.d.ts.map +1 -0
  55. package/lib/types/merge-streams.d.ts +27 -0
  56. package/lib/types/merge-streams.d.ts.map +1 -1
  57. package/lib/types/relay-filter.d.ts +4 -3
  58. package/lib/types/relay-filter.d.ts.map +1 -1
  59. package/lib/types/relay-server.d.ts.map +1 -1
  60. package/package.json +6 -3
  61. package/pwa/app.css +13 -2
  62. package/scripts/check-debug-report.mjs +277 -0
  63. package/scripts/check-remote-drop.mjs +130 -0
  64. package/scripts/check-tablet-safe-area.mjs +49 -0
  65. package/src/client/effects/remote-drop.ts +271 -24
  66. package/src/client/locales.ts +20 -4
  67. package/src/client/settings/SettingsSection.tsx +120 -3
  68. package/src/client/styles/misc.css.ts +38 -0
  69. package/src/client-data/remote-drop.ts +103 -0
  70. package/src/client-data/settings-form.ts +3 -0
  71. package/src/client-pairing.ts +11 -0
  72. package/src/client-routes.ts +196 -90
  73. package/src/debug-redact.ts +290 -0
  74. package/src/debug-report.ts +93 -0
  75. package/src/fetch-route-intercept.ts +29 -0
  76. package/src/index.ts +282 -43
  77. package/src/log-ring.ts +101 -0
  78. package/src/merge-streams.ts +71 -16
  79. package/src/relay-filter.ts +4 -3
  80. 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-session drop guard (RD-2): a document-level CAPTURE-phase drop
9
- * listener that re-shapes file drops landing in a remote session before the
10
- * host's own (bubble-phase, F4) handler sees them.
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 function): the event
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), and the current session (`dsh.sessions.
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 drop,
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 listener is mounted and armed (capability check passed, effect
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
- /** Drops this guard stopped from reaching the host (both the blocked and
52
- * the redispatched kind). */
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
- /** Drops stopped because they carried a folder (toast shown, nothing
57
- * dispatched). */
76
+ /** Intakes stopped because they carried a folder (toast shown, nothing
77
+ * dispatched) (= blocked sum). */
58
78
  blockedDirectories: number
59
- /** Drops judged not-ours and released untouched. */
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 { installed: false, notInstalledReason: undefined, intercepted: 0, redispatched: 0, blockedDirectories: 0, passthrough: 0, rewrapFailed: 0 }
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 { ...diagnostics }
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 rewrapped = rewrapFiles(Array.from(transfer.files))
238
- if (rewrapped.length === 0) {
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
- document.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: payload }))
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.
@@ -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 remote drop guard's diagnostics line (browser-side
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': 'Remote drop guard',
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 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. */}
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
  }