dsh-zen-remote 2.3.6 → 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.
Files changed (66) hide show
  1. package/AGENTS.md +15 -5
  2. package/CHANGELOG.md +71 -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/types/client/effects/remote-drop.d.ts +20 -8
  25. package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
  26. package/lib/types/client/locales.d.ts +8 -1
  27. package/lib/types/client/locales.d.ts.map +1 -1
  28. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  29. package/lib/types/client/styles/misc.css.d.ts +1 -1
  30. package/lib/types/client/styles/misc.css.d.ts.map +1 -1
  31. package/lib/types/client-data/remote-drop.d.ts +64 -0
  32. package/lib/types/client-data/remote-drop.d.ts.map +1 -1
  33. package/lib/types/client-data/settings-form.d.ts +3 -0
  34. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  35. package/lib/types/client-pairing.d.ts +8 -0
  36. package/lib/types/client-pairing.d.ts.map +1 -1
  37. package/lib/types/client-routes.d.ts +23 -0
  38. package/lib/types/client-routes.d.ts.map +1 -1
  39. package/lib/types/debug-redact.d.ts +68 -0
  40. package/lib/types/debug-redact.d.ts.map +1 -0
  41. package/lib/types/debug-report.d.ts +58 -0
  42. package/lib/types/debug-report.d.ts.map +1 -0
  43. package/lib/types/fetch-route-intercept.d.ts +26 -0
  44. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  45. package/lib/types/index.d.ts +7 -1
  46. package/lib/types/index.d.ts.map +1 -1
  47. package/lib/types/log-ring.d.ts +57 -0
  48. package/lib/types/log-ring.d.ts.map +1 -0
  49. package/package.json +3 -2
  50. package/pwa/app.css +13 -2
  51. package/scripts/check-debug-report.mjs +277 -0
  52. package/scripts/check-remote-drop.mjs +130 -0
  53. package/scripts/check-tablet-safe-area.mjs +49 -0
  54. package/src/client/effects/remote-drop.ts +271 -24
  55. package/src/client/locales.ts +20 -4
  56. package/src/client/settings/SettingsSection.tsx +120 -3
  57. package/src/client/styles/misc.css.ts +38 -0
  58. package/src/client-data/remote-drop.ts +103 -0
  59. package/src/client-data/settings-form.ts +3 -0
  60. package/src/client-pairing.ts +11 -0
  61. package/src/client-routes.ts +196 -90
  62. package/src/debug-redact.ts +290 -0
  63. package/src/debug-report.ts +93 -0
  64. package/src/fetch-route-intercept.ts +29 -0
  65. package/src/index.ts +282 -43
  66. package/src/log-ring.ts +101 -0
package/lib/client.js CHANGED
@@ -2067,7 +2067,7 @@ __modules["client-data/settings-form.js"] = function (require, module, exports)
2067
2067
  * (devices / pairing) plus the T14 envelope (ok / gateway / config / viaGateway).
2068
2068
  */
2069
2069
  Object.defineProperty(exports, "__esModule", { value: true });
2070
- exports.ZenRemoteSettingsForm = exports.SETTINGS_FIELDS = exports.IDLE_HOURS_MAX = exports.SERVER_NAME_MAX = exports.RESTART_FIELDS = exports.PAIRING_CODE_LENGTH = exports.PAIRING_CODE_ALPHABET = exports.DEVICE_TOKEN_FIELD = exports.CLIENT_CONFIG_ROUTE = exports.CLIENT_RECONNECT_ROUTE = exports.CLIENT_STATUS_ROUTE = exports.CLIENT_CLAIM_ROUTE = exports.ADMIN_PLUGIN_BRIDGE_ROUTE = exports.ADMIN_PUSH_TEST_ROUTE = exports.ADMIN_ACTION_ROUTE = exports.ADMIN_PAIR_ROUTE = exports.ADMIN_STATUS_ROUTE = void 0;
2070
+ exports.ZenRemoteSettingsForm = exports.SETTINGS_FIELDS = exports.IDLE_HOURS_MAX = exports.SERVER_NAME_MAX = exports.RESTART_FIELDS = exports.PAIRING_CODE_LENGTH = exports.PAIRING_CODE_ALPHABET = exports.DEVICE_TOKEN_FIELD = exports.CLIENT_CONFIG_ROUTE = exports.CLIENT_DEBUG_REPORT_ROUTE = exports.CLIENT_RECONNECT_ROUTE = exports.CLIENT_STATUS_ROUTE = exports.CLIENT_CLAIM_ROUTE = exports.ADMIN_PLUGIN_BRIDGE_ROUTE = exports.ADMIN_PUSH_TEST_ROUTE = exports.ADMIN_ACTION_ROUTE = exports.ADMIN_PAIR_ROUTE = exports.ADMIN_STATUS_ROUTE = void 0;
2071
2071
  exports.deriveClientStatusView = deriveClientStatusView;
2072
2072
  exports.clientStatusLineOf = clientStatusLineOf;
2073
2073
  exports.normalizePairingCode = normalizePairingCode;
@@ -2100,6 +2100,9 @@ exports.CLIENT_CLAIM_ROUTE = '/_dsh/zen-remote/client/claim';
2100
2100
  exports.CLIENT_STATUS_ROUTE = '/_dsh/zen-remote/client/status';
2101
2101
  /** T43: one immediate reconnect — answered 409 unless the client is offline. */
2102
2102
  exports.CLIENT_RECONNECT_ROUTE = '/_dsh/zen-remote/client/reconnect';
2103
+ /** CU-4: the redacted diagnostic report (GET, both roles, machine-local only
2104
+ * — a gateway-forwarded fetch is refused server-side). */
2105
+ exports.CLIENT_DEBUG_REPORT_ROUTE = '/_dsh/zen-remote/client/debug-report';
2103
2106
  /** The lightweight client-facing config route (host half, both roles): the
2104
2107
  * settings page's FALLBACK role probe (T17) — it registers wherever a
2105
2108
  * webServer exists, and since T17 its body carries the effective `role`
@@ -3303,15 +3306,23 @@ exports.zh = {
3303
3306
  'settings.client.diagRecentFailures': '最近的远程调用失败',
3304
3307
  'settings.client.diagCompatGroups': '指纹有差异的组:{groups}',
3305
3308
  'settings.client.diagCompatCalls': '最近的不兼容调用',
3306
- // --- RD-3 远程会话拖拽守卫的诊断行(浏览器端计数,渲染时现读) ---
3307
- 'settings.client.diagRemoteDrop': '远程拖拽守卫',
3309
+ // --- RD-3/AU-3 附件入口守卫的诊断行(浏览器端计数,渲染时现读) ---
3310
+ 'settings.client.diagRemoteDrop': '附件入口守卫',
3308
3311
  'settings.client.diagRemoteDropOk': '已安装 · 拦截 {intercepted} · 重派发 {redispatched} · 文件夹 {blocked} · 放行 {passthrough} · 重包失败 {failed}',
3312
+ 'settings.client.diagRemoteDropEntries': '分入口 · 拦截 拖拽 {dropI} / 回形针 {pickerI} / 粘贴 {pasteI} · 放行 拖拽 {dropP} / 回形针 {pickerP} / 粘贴 {pasteP}',
3309
3313
  'settings.client.diagRemoteDropOff': '未安装({reason})',
3310
3314
  // --- RD-6 上传改道(fetch-route 拦截)的诊断行:读 remote-status 的 serviceProbe ---
3311
3315
  'settings.client.diagUploadRedirect': '上传改道',
3312
3316
  'settings.client.diagUploadRedirectOk': '已安装 · 上传已接管',
3313
3317
  'settings.client.diagUploadRedirectRefused': '拒装:{reason}',
3314
3318
  'settings.client.diagUploadRedirectPending': '服务注入未触发 · connection {connection} · fileUploads {fileUploads}',
3319
+ // --- CU-4 诊断信息导出(设置页最底部,两种角色都有) ---
3320
+ 'settings.debug.title': '诊断信息',
3321
+ 'settings.debug.export': '导出诊断信息',
3322
+ 'settings.debug.exporting': '正在生成…',
3323
+ 'settings.debug.hint': '下载一份脱敏后的诊断文件(版本、配置形态、连接诊断、插件日志)。令牌与密钥、对话内容、附件和完整文件路径一律不包含,服务器地址只保留类型。建议从机与主服务器各导一份,可直接附到 GitHub issue。',
3324
+ 'settings.debug.done': '诊断文件已下载',
3325
+ 'settings.debug.fail': '导出失败:无法生成诊断报告',
3315
3326
  // --- PB-5 插件桥:主服务端同意小节 + 子客户端诊断小节 ---
3316
3327
  'settings.bridge.title': '插件桥',
3317
3328
  'settings.bridge.empty': '暂无插件登记插件桥',
@@ -3813,16 +3824,24 @@ exports.en = {
3813
3824
  'settings.client.diagRecentFailures': 'Recent remote call failures',
3814
3825
  'settings.client.diagCompatGroups': 'Groups with fingerprint differences: {groups}',
3815
3826
  'settings.client.diagCompatCalls': 'Recent incompatible calls',
3816
- // --- RD-3: the remote drop guard's diagnostics line (browser-side
3827
+ // --- RD-3/AU-3: the attachment-entry guard's diagnostics line (browser-side
3817
3828
  // counters, read at render time) ---
3818
- 'settings.client.diagRemoteDrop': 'Remote drop guard',
3829
+ 'settings.client.diagRemoteDrop': 'Attachment entry guard',
3819
3830
  'settings.client.diagRemoteDropOk': 'Installed · intercepted {intercepted} · redispatched {redispatched} · folders {blocked} · passthrough {passthrough} · rewrap failed {failed}',
3831
+ 'settings.client.diagRemoteDropEntries': 'By entry · intercepted drop {dropI} / picker {pickerI} / paste {pasteI} · passthrough drop {dropP} / picker {pickerP} / paste {pasteP}',
3820
3832
  'settings.client.diagRemoteDropOff': 'Not installed ({reason})',
3821
3833
  // --- RD-6: the upload-redirect (fetch-route intercept) diagnostics row ---
3822
3834
  'settings.client.diagUploadRedirect': 'Upload redirect',
3823
3835
  'settings.client.diagUploadRedirectOk': 'Installed · upload wrapped',
3824
3836
  'settings.client.diagUploadRedirectRefused': 'Refused: {reason}',
3825
3837
  'settings.client.diagUploadRedirectPending': 'Service inject never fired · connection {connection} · fileUploads {fileUploads}',
3838
+ // --- CU-4: the diagnostics export (bottom of the settings page, both roles) ---
3839
+ 'settings.debug.title': 'Diagnostics',
3840
+ 'settings.debug.export': 'Export diagnostics',
3841
+ 'settings.debug.exporting': 'Generating…',
3842
+ '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.',
3843
+ 'settings.debug.done': 'Diagnostics file downloaded',
3844
+ 'settings.debug.fail': 'Export failed: the diagnostics report could not be generated',
3826
3845
  // --- PB-5 plugin bridge: host consent section + sub-client diagnostics ---
3827
3846
  'settings.bridge.title': 'Plugin bridge',
3828
3847
  'settings.bridge.empty': 'No plugin has registered a bridge yet',
@@ -9771,6 +9790,44 @@ exports.MISC_CSS = ` /* ---------- hero composer on mobile ----------
9771
9790
  display: none !important;
9772
9791
  }
9773
9792
  }
9793
+
9794
+ /* ---------- tablet standalone PWA: pay the safe area above 1023px ---------- */
9795
+ /* The gateway serves viewport-fit=cover to every non-desktop device (see
9796
+ coverViewport in lan-gate-server), phone-chrome only manages the meta at
9797
+ <=1023px, and every compensation rule in layout.css lives behind the same
9798
+ 1023px breakpoint and the data-mobile-nav frame marker MobileNavOverlay
9799
+ only sets there. A wide tablet's home-screen PWA — iPad landscape, the
9800
+ 12.9"/13" iPad portrait at exactly 1024, Android tablets — therefore
9801
+ painted under the status bar with nothing paying the inset: the top row
9802
+ of controls sat in the system area and was hard to tap.
9803
+
9804
+ Anchor: the official AppFrame (ui-layout AppFrame.tsx) is the direct
9805
+ parent of the [data-shell-overlay] layer — the same element
9806
+ MobileNavOverlay's findFrame() resolves — and the frame already pads
9807
+ ITSELF for the Windows titlebar (padding-top: --dsh-windows-titlebar-height
9808
+ in AppFrame.module.css), so padding the frame is the host's own pattern
9809
+ for system bars.
9810
+
9811
+ Structural no-op everywhere else, by construction:
9812
+ - display-mode: standalone matches only an installed PWA; desktop
9813
+ browsers report browser.
9814
+ - The Electron app never receives this stylesheet at all (the
9815
+ isDesktopShell gate in client/index.tsx) and never gets cover.
9816
+ - env() insets are hard 0 in every desktop browser, so even a browser
9817
+ that somehow matched the media query computes 0px padding; on iPads
9818
+ the bottom inset is 0 too (no home indicator) — the bottom line only
9819
+ ever pays out on Android tablets with a gesture/nav bar.
9820
+ box-sizing is load-bearing (the S2.1 lesson, see the frame rule in
9821
+ layout.css): the official frame is height:100% content-box, and padding
9822
+ without border-box would grow it past the viewport instead of reserving
9823
+ the strip. Pinned by scripts/check-tablet-safe-area.mjs. */
9824
+ @media (min-width: 1024px) and (display-mode: standalone) {
9825
+ :has(> [data-shell-overlay]) {
9826
+ box-sizing: border-box !important;
9827
+ padding-top: var(--mnav-sat) !important;
9828
+ padding-bottom: var(--mnav-sab) !important;
9829
+ }
9830
+ }
9774
9831
  `;
9775
9832
  };
9776
9833
  __modules["client/styles/home.css.js"] = function (require, module, exports) {
@@ -16223,6 +16280,69 @@ const EXPORT_STYLE = {
16223
16280
  whiteSpace: 'nowrap',
16224
16281
  };
16225
16282
  };
16283
+ __modules["client-data/current-session.js"] = function (require, module, exports) {
16284
+ "use strict";
16285
+ /**
16286
+ * T62: the browser half's current-session parsing, as pure functions — the
16287
+ * readable core of the reporter (src/client/current-session-report.ts) and
16288
+ * the one place its rules are unit-tested without a DOM.
16289
+ *
16290
+ * The host DSH persists its selection (the session its UI has open) to
16291
+ * localStorage under `dsh.sessions.current` as a JSON object carrying
16292
+ * `sessionId` (dsh-client-ui-workspace's snapshot store, `replaceMain` /
16293
+ * `clearMain`). The reporter re-reads that key and POSTs it to the backend
16294
+ * whenever it CHANGES; the backend's intercept layer judges close frames
16295
+ * against it — a closed session the user has open keeps its page and its
16296
+ * 「远程已关闭」 banner, anything else hides. The vocabulary mirrors the
16297
+ * intercept side's `CurrentSessionRead`:
16298
+ * - `unavailable`: the key is absent or the value unreadable — NEVER
16299
+ * report; the backend keeps its conservative tombstone behavior;
16300
+ * - `none`: readable, no session open — report `{sessionId: null}`;
16301
+ * - `open`: a session id — report `{sessionId}`.
16302
+ */
16303
+ Object.defineProperty(exports, "__esModule", { value: true });
16304
+ exports.CURRENT_SESSION_STORAGE_KEY = void 0;
16305
+ exports.parseCurrentSessionStorage = parseCurrentSessionStorage;
16306
+ exports.currentSessionReportEquals = currentSessionReportEquals;
16307
+ /** Mirror of src/intercept.ts's key (the client build cannot import the
16308
+ * host file). The host's selection store persists under this exact name. */
16309
+ exports.CURRENT_SESSION_STORAGE_KEY = 'dsh.sessions.current';
16310
+ /** Parse one raw localStorage read. `null` / `undefined` (the key is absent
16311
+ * — the host store only starts persisting with its first write) and any
16312
+ * value that does not parse into an object with a usable shape are
16313
+ * `unavailable`: no signal, never a guess. An object WITHOUT a `sessionId`
16314
+ * (the host's `clearMain` writes `{}`) is a real, reportable "nothing is
16315
+ * open". */
16316
+ function parseCurrentSessionStorage(raw) {
16317
+ if (raw === null || raw === undefined)
16318
+ return { kind: 'unavailable' };
16319
+ let parsed;
16320
+ try {
16321
+ parsed = JSON.parse(raw);
16322
+ }
16323
+ catch {
16324
+ return { kind: 'unavailable' };
16325
+ }
16326
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
16327
+ return { kind: 'unavailable' };
16328
+ const sessionId = parsed.sessionId;
16329
+ if (typeof sessionId === 'string' && sessionId !== '')
16330
+ return { kind: 'open', sessionId };
16331
+ if (sessionId === undefined || sessionId === null || sessionId === '')
16332
+ return { kind: 'none' };
16333
+ return { kind: 'unavailable' };
16334
+ }
16335
+ /** Whether one report differs from another — the reporter posts only on a
16336
+ * change, so this compares content, never object identity: kind first, then
16337
+ * the session id. */
16338
+ function currentSessionReportEquals(a, b) {
16339
+ if (a.kind !== b.kind)
16340
+ return false;
16341
+ if (a.kind === 'open' && b.kind === 'open')
16342
+ return a.sessionId === b.sessionId;
16343
+ return true;
16344
+ }
16345
+ };
16226
16346
  __modules["virtual-id.js"] = function (require, module, exports) {
16227
16347
  "use strict";
16228
16348
  /**
@@ -16335,6 +16455,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
16335
16455
  exports.isRemoteSessionId = isRemoteSessionId;
16336
16456
  exports.decideRemoteDrop = decideRemoteDrop;
16337
16457
  exports.rewrapFiles = rewrapFiles;
16458
+ exports.decideRemoteIntake = decideRemoteIntake;
16338
16459
  const virtual_id_ts_1 = require("./virtual-id.js");
16339
16460
  /** True when the id is one of this plugin's virtual (relay) session ids —
16340
16461
  * the `zr~<serverId>~<id>` form. `undefined` (no readable current-session
@@ -16387,68 +16508,48 @@ function decideRemoteDrop(input) {
16387
16508
  function rewrapFiles(files) {
16388
16509
  return Array.from(files, (file) => new File([file], file.name, { type: file.type, lastModified: file.lastModified }));
16389
16510
  }
16390
- };
16391
- __modules["client-data/current-session.js"] = function (require, module, exports) {
16392
- "use strict";
16393
16511
  /**
16394
- * T62: the browser half's current-session parsing, as pure functions — the
16395
- * readable core of the reporter (src/client/current-session-report.ts) and
16396
- * the one place its rules are unit-tested without a DOM.
16512
+ * The three gates, then the intervention rule. The gates are decideRemoteDrop's
16513
+ * verbatim (all must affirmatively pass — a missing signal reads as local and
16514
+ * never intervenes); the rule after them:
16515
+ * 1. `items === undefined` — no inventory, pass through (`no-entry-api`;
16516
+ * deciding blind could eat a working flow);
16517
+ * 2. any directory — `block-directories`, ahead of every redirect: a folder
16518
+ * cannot be fixed by single-file upload, the whole intake is stopped with
16519
+ * a toast (same semantics as the drop branch);
16520
+ * 3. no items at all — passthrough (`no-files`; the plain-text paste shape);
16521
+ * 4. otherwise — `redirect` when ANY item is a `drop` item (the frozen
16522
+ * "any file intervenes" semantics) or carries a path while not being an
16523
+ * image (the zero-copy bad branch — exactly what issue #49 broke on);
16524
+ * `no-path-or-image-only` passthrough when none does.
16397
16525
  *
16398
- * The host DSH persists its selection (the session its UI has open) to
16399
- * localStorage under `dsh.sessions.current` as a JSON object carrying
16400
- * `sessionId` (dsh-client-ui-workspace's snapshot store, `replaceMain` /
16401
- * `clearMain`). The reporter re-reads that key and POSTs it to the backend
16402
- * whenever it CHANGES; the backend's intercept layer judges close frames
16403
- * against it — a closed session the user has open keeps its page and its
16404
- * 「远程已关闭」 banner, anything else hides. The vocabulary mirrors the
16405
- * intercept side's `CurrentSessionRead`:
16406
- * - `unavailable`: the key is absent or the value unreadable — NEVER
16407
- * report; the backend keeps its conservative tombstone behavior;
16408
- * - `none`: readable, no session open — report `{sessionId: null}`;
16409
- * - `open`: a session id — report `{sessionId}`.
16410
- */
16411
- Object.defineProperty(exports, "__esModule", { value: true });
16412
- exports.CURRENT_SESSION_STORAGE_KEY = void 0;
16413
- exports.parseCurrentSessionStorage = parseCurrentSessionStorage;
16414
- exports.currentSessionReportEquals = currentSessionReportEquals;
16415
- /** Mirror of src/intercept.ts's key (the client build cannot import the
16416
- * host file). The host's selection store persists under this exact name. */
16417
- exports.CURRENT_SESSION_STORAGE_KEY = 'dsh.sessions.current';
16418
- /** Parse one raw localStorage read. `null` / `undefined` (the key is absent
16419
- * — the host store only starts persisting with its first write) and any
16420
- * value that does not parse into an object with a usable shape are
16421
- * `unavailable`: no signal, never a guess. An object WITHOUT a `sessionId`
16422
- * (the host's `clearMain` writes `{}`) is a real, reportable "nothing is
16423
- * open". */
16424
- function parseCurrentSessionStorage(raw) {
16425
- if (raw === null || raw === undefined)
16426
- return { kind: 'unavailable' };
16427
- let parsed;
16428
- try {
16429
- parsed = JSON.parse(raw);
16430
- }
16431
- catch {
16432
- return { kind: 'unavailable' };
16433
- }
16434
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
16435
- return { kind: 'unavailable' };
16436
- const sessionId = parsed.sessionId;
16437
- if (typeof sessionId === 'string' && sessionId !== '')
16438
- return { kind: 'open', sessionId };
16439
- if (sessionId === undefined || sessionId === null || sessionId === '')
16440
- return { kind: 'none' };
16441
- return { kind: 'unavailable' };
16442
- }
16443
- /** Whether one report differs from another — the reporter posts only on a
16444
- * change, so this compares content, never object identity: kind first, then
16445
- * the session id. */
16446
- function currentSessionReportEquals(a, b) {
16447
- if (a.kind !== b.kind)
16448
- return false;
16449
- if (a.kind === 'open' && b.kind === 'open')
16450
- return a.sessionId === b.sessionId;
16451
- return true;
16526
+ * Drop-entry parity pin: a `drop` item's intervention semantics here are
16527
+ * kept EQUIVALENT to `decideRemoteDrop` — same gates, directory rule and
16528
+ * "any file intervenes" — with exactly ONE deliberate divergence: when
16529
+ * `items === undefined`, `decideRemoteDrop` redispatches (a drop always has
16530
+ * a Files payload worth re-shaping) while this function passes through
16531
+ * (`no-entry-api` — an intake caller that cannot even enumerate its items
16532
+ * must not be intercepted blind). If either function's gate sequence or
16533
+ * drop-entry rule changes, update the other in the same commit; the truth
16534
+ * tables in scripts/check-remote-drop.mjs pin both sides.
16535
+ */
16536
+ function decideRemoteIntake(input) {
16537
+ if (!input.trusted)
16538
+ return { action: 'passthrough', reason: 'untrusted-event' };
16539
+ if (!input.hasHostPaths)
16540
+ return { action: 'passthrough', reason: 'no-host-paths' };
16541
+ if (!isRemoteSessionId(input.currentSessionId))
16542
+ return { action: 'passthrough', reason: 'not-remote-session' };
16543
+ if (input.items === undefined)
16544
+ return { action: 'passthrough', reason: 'no-entry-api' };
16545
+ if (input.items.some((item) => item.isDirectory))
16546
+ return { action: 'block-directories', reason: 'contains-directory' };
16547
+ if (input.items.length === 0)
16548
+ return { action: 'passthrough', reason: 'no-files' };
16549
+ const intervenes = input.items.some((item) => item.entry === 'drop' || (item.hasPath && !item.isImage));
16550
+ return intervenes
16551
+ ? { action: 'redirect', reason: 'remote-path-files' }
16552
+ : { action: 'passthrough', reason: 'no-path-or-image-only' };
16452
16553
  }
16453
16554
  };
16454
16555
  __modules["client/effects/remote-drop.js"] = function (require, module, exports) {
@@ -16463,18 +16564,60 @@ const locales_ts_1 = require("./client/locales.js");
16463
16564
  * Re-mounting (a plugin row reload) resets it. */
16464
16565
  let diagnostics = freshDiagnostics();
16465
16566
  function freshDiagnostics() {
16466
- return { installed: false, notInstalledReason: undefined, intercepted: 0, redispatched: 0, blockedDirectories: 0, passthrough: 0, rewrapFailed: 0 };
16567
+ return {
16568
+ installed: false,
16569
+ notInstalledReason: undefined,
16570
+ intercepted: 0,
16571
+ redispatched: 0,
16572
+ blockedDirectories: 0,
16573
+ passthrough: 0,
16574
+ rewrapFailed: 0,
16575
+ interceptedBy: { drop: 0, picker: 0, paste: 0 },
16576
+ redirectedBy: { drop: 0, picker: 0, paste: 0 },
16577
+ blockedBy: { drop: 0, picker: 0, paste: 0 },
16578
+ passthroughBy: { drop: 0, picker: 0, paste: 0 },
16579
+ };
16467
16580
  }
16468
16581
  /** A snapshot for the diagnostics carriers (settings page). Copying keeps
16469
- * callers from holding a live reference into a later mount's state. */
16582
+ * callers from holding a live reference into a later mount's state — the
16583
+ * per-entry objects are copied too, or a caller would keep counting. */
16470
16584
  function remoteDropDiagnostics() {
16471
- return { ...diagnostics };
16585
+ return {
16586
+ ...diagnostics,
16587
+ interceptedBy: { ...diagnostics.interceptedBy },
16588
+ redirectedBy: { ...diagnostics.redirectedBy },
16589
+ blockedBy: { ...diagnostics.blockedBy },
16590
+ passthroughBy: { ...diagnostics.passthroughBy },
16591
+ };
16592
+ }
16593
+ function bump(counts, entry) {
16594
+ counts[entry] += 1;
16472
16595
  }
16473
16596
  /** The contextBridge global's existence only — never touched otherwise
16474
16597
  * (it is deep-frozen and must stay unwrapped). */
16475
16598
  function hostPathsOf() {
16476
16599
  return globalThis.__DSH_HOST_PATHS__;
16477
16600
  }
16601
+ /** The bridge's `pathFor` — the preload's `webUtils.getPathForFile`. A
16602
+ * non-empty string means the File still carries its disk path, the exact
16603
+ * fact the host's zero-copy branch keys on. Anything else (bridge shape,
16604
+ * non-string answer, a throw) reads as "no path" — the conservative
16605
+ * direction: the file is treated as already byte-uploading and stays
16606
+ * untouched. */
16607
+ function bridgePathFor(bridge, file) {
16608
+ if (bridge === null || typeof bridge !== 'object')
16609
+ return '';
16610
+ const pathFor = bridge.pathFor;
16611
+ if (typeof pathFor !== 'function')
16612
+ return '';
16613
+ try {
16614
+ const result = pathFor.call(bridge, file);
16615
+ return typeof result === 'string' ? result : '';
16616
+ }
16617
+ catch {
16618
+ return '';
16619
+ }
16620
+ }
16478
16621
  /** The current-session signal, read AT EVENT TIME (the selection changes
16479
16622
  * while the page is open; a mount-time snapshot would go stale). Any
16480
16623
  * storage failure reads as "no signal" = local session = passthrough. */
@@ -16533,6 +16676,28 @@ function dismissHostDropOverlay() {
16533
16676
  // No window / Event in this host: the overlay is the only casualty.
16534
16677
  }
16535
16678
  }
16679
+ /** Build the redispatch payload WITHOUT touching any event: re-wrap the files
16680
+ * (path-stripped) and pack them into a fresh DataTransfer. `undefined` means
16681
+ * the file list was empty (nothing to dispatch — the caller passes through);
16682
+ * a throw means a re-wrap or an items.add failed (0-byte / exotic File) and
16683
+ * the caller releases the original event untouched (宁坏勿丢). Shared by all
16684
+ * three entries — the payload shape is the redispatch contract, and the
16685
+ * drop entry's behavior is frozen by its existing tests. */
16686
+ function buildDropPayload(files) {
16687
+ const rewrapped = (0, remote_drop_ts_1.rewrapFiles)(files);
16688
+ if (rewrapped.length === 0)
16689
+ return undefined;
16690
+ const payload = new DataTransfer();
16691
+ for (const file of rewrapped)
16692
+ payload.items.add(file);
16693
+ return payload;
16694
+ }
16695
+ /** Dispatch the host-shaped synthetic drop on document, bubble phase — the
16696
+ * host's own drop handler (mask reset, busy rejection, addFiles) digests it;
16697
+ * our capture listener skips it via the isTrusted gate. */
16698
+ function dispatchSyntheticDrop(payload) {
16699
+ document.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: payload }));
16700
+ }
16536
16701
  function mountRemoteDropGuard(ctx) {
16537
16702
  ctx.effect(() => {
16538
16703
  // Fresh state per mount: a plugin reload re-runs apply() and must not
@@ -16609,6 +16774,7 @@ function mountRemoteDropGuard(ctx) {
16609
16774
  });
16610
16775
  if (decision.action === 'passthrough') {
16611
16776
  diagnostics.passthrough += 1;
16777
+ bump(diagnostics.passthroughBy, 'drop');
16612
16778
  return;
16613
16779
  }
16614
16780
  if (decision.action === 'block-directories') {
@@ -16618,7 +16784,9 @@ function mountRemoteDropGuard(ctx) {
16618
16784
  event.stopPropagation();
16619
16785
  dismissHostDropOverlay();
16620
16786
  diagnostics.intercepted += 1;
16787
+ bump(diagnostics.interceptedBy, 'drop');
16621
16788
  diagnostics.blockedDirectories += 1;
16789
+ bump(diagnostics.blockedBy, 'drop');
16622
16790
  showToast();
16623
16791
  return;
16624
16792
  }
@@ -16627,23 +16795,23 @@ function mountRemoteDropGuard(ctx) {
16627
16795
  // host still receives today's (path-chip) behavior instead of
16628
16796
  // nothing at all.
16629
16797
  try {
16630
- const rewrapped = (0, remote_drop_ts_1.rewrapFiles)(Array.from(transfer.files));
16631
- if (rewrapped.length === 0) {
16798
+ const payload = buildDropPayload(Array.from(transfer.files));
16799
+ if (payload === undefined) {
16632
16800
  diagnostics.passthrough += 1;
16801
+ bump(diagnostics.passthroughBy, 'drop');
16633
16802
  return;
16634
16803
  }
16635
- const payload = new DataTransfer();
16636
- for (const file of rewrapped)
16637
- payload.items.add(file);
16638
16804
  event.preventDefault();
16639
16805
  event.stopPropagation();
16640
16806
  diagnostics.intercepted += 1;
16807
+ bump(diagnostics.interceptedBy, 'drop');
16641
16808
  // The host's drop handler sits on document, bubble phase (F4): a
16642
16809
  // bubbling synthetic drop on document reaches it. Its own handler
16643
16810
  // does the preventDefault / mask reset / addFiles work; our capture
16644
16811
  // listener skips this event via the isTrusted gate above.
16645
- document.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: payload }));
16812
+ dispatchSyntheticDrop(payload);
16646
16813
  diagnostics.redispatched += 1;
16814
+ bump(diagnostics.redirectedBy, 'drop');
16647
16815
  }
16648
16816
  catch {
16649
16817
  // A re-wrap or a single items.add threw (0-byte / exotic File):
@@ -16652,8 +16820,173 @@ function mountRemoteDropGuard(ctx) {
16652
16820
  }
16653
16821
  };
16654
16822
  document.addEventListener('drop', onDrop, true);
16823
+ // --- the picker entry (AU-2): the composer's hidden file selector -------
16824
+ // F1: the host composer's picker is an `input[type=file][multiple][hidden]`
16825
+ // with NO accept attribute, and its React onChange feeds intakeFiles →
16826
+ // addFiles — the same zero-copy branch the drop entry guards. Judged by
16827
+ // STRUCTURE, never by class names (hash classes cannot be anchored): an
16828
+ // input with an accept attribute (import / restore pickers), a visible
16829
+ // one, or a `webkitdirectory` folder picker (a third-party composition
16830
+ // could combine all four) is somebody else's and passes through. The
16831
+ // host composer has none of those, so the hit is unchanged.
16832
+ const onChange = (event) => {
16833
+ if (!event.isTrusted)
16834
+ return;
16835
+ const input = event.target;
16836
+ if (!(input instanceof HTMLInputElement))
16837
+ return;
16838
+ if (input.type !== 'file' || !input.multiple || !input.hidden || input.hasAttribute('accept') || input.webkitdirectory)
16839
+ return;
16840
+ const files = Array.from(input.files ?? []);
16841
+ if (files.length === 0)
16842
+ return;
16843
+ // The remote gate first, BEFORE counting: a local session's everyday
16844
+ // picker use must not show up in the diagnostics at all (the counters
16845
+ // are a record of what the guard saw in remote sessions).
16846
+ if (!(0, remote_drop_ts_1.isRemoteSessionId)(readCurrentSessionId()))
16847
+ return;
16848
+ const bridge = hostPathsOf();
16849
+ const items = files.map((file) => ({
16850
+ entry: 'picker',
16851
+ // A picker yields plain files; directories are structurally
16852
+ // impossible, so the block-directories branch is unreachable here
16853
+ // (defensive passthrough below).
16854
+ isDirectory: false,
16855
+ hasPath: bridgePathFor(bridge, file) !== '',
16856
+ isImage: file.type.startsWith('image/'),
16857
+ }));
16858
+ const decision = (0, remote_drop_ts_1.decideRemoteIntake)({
16859
+ hasHostPaths: bridge !== undefined,
16860
+ currentSessionId: readCurrentSessionId(),
16861
+ trusted: event.isTrusted,
16862
+ items,
16863
+ });
16864
+ if (decision.action !== 'redirect') {
16865
+ // passthrough, or the defensive block branch that cannot happen for
16866
+ // a picker — either way zero intervention.
16867
+ diagnostics.passthrough += 1;
16868
+ bump(diagnostics.passthroughBy, 'picker');
16869
+ return;
16870
+ }
16871
+ // Build the payload BEFORE stopping the event (宁坏勿丢): a re-wrap
16872
+ // failure releases the change untouched — the host keeps today's
16873
+ // (path-chip) behavior instead of losing the pick entirely.
16874
+ try {
16875
+ const payload = buildDropPayload(files);
16876
+ if (payload === undefined) {
16877
+ diagnostics.passthrough += 1;
16878
+ bump(diagnostics.passthroughBy, 'picker');
16879
+ return;
16880
+ }
16881
+ // stopPropagation keeps the event from reaching React's delegated
16882
+ // onChange at the root container (F5); clearing the value lets the
16883
+ // SAME file trigger a later change again (the host's own onPickFiles
16884
+ // habit, applied here since the host's handler no longer runs).
16885
+ event.stopPropagation();
16886
+ input.value = '';
16887
+ diagnostics.intercepted += 1;
16888
+ bump(diagnostics.interceptedBy, 'picker');
16889
+ dispatchSyntheticDrop(payload);
16890
+ diagnostics.redispatched += 1;
16891
+ bump(diagnostics.redirectedBy, 'picker');
16892
+ }
16893
+ catch {
16894
+ diagnostics.rewrapFailed += 1;
16895
+ }
16896
+ };
16897
+ document.addEventListener('change', onChange, true);
16898
+ // --- the paste entry (AU-2): the editor's clipboard files ---------------
16899
+ // F2: the composer editor (a contenteditable root) reads file items off
16900
+ // clipboardData; a path-carrying non-image lands on the same zero-copy
16901
+ // branch. Pasting into any other target (settings fields, search) is not
16902
+ // ours. clipboardData is read-only, so a redirect can only re-dispatch
16903
+ // — preventDefault keeps the editor from inserting anything.
16904
+ const onPaste = (event) => {
16905
+ if (!event.isTrusted)
16906
+ return;
16907
+ const transfer = event.clipboardData;
16908
+ if (transfer === null)
16909
+ return;
16910
+ const firstElement = event.composedPath().find((node) => node instanceof Element);
16911
+ if (firstElement === undefined || firstElement.closest('[contenteditable="true"]') === null)
16912
+ return;
16913
+ // The remote gate BEFORE any per-item work (same rationale as picker):
16914
+ // a local session's paste — including pasted FILES — returns here, so
16915
+ // it never runs bridgePathFor per file and never reaches the counters.
16916
+ // Everything after this point still runs inside this one synchronous
16917
+ // handler, within the dispatch lifetime clipboardData requires.
16918
+ if (!(0, remote_drop_ts_1.isRemoteSessionId)(readCurrentSessionId()))
16919
+ return;
16920
+ const bridge = hostPathsOf();
16921
+ const files = [];
16922
+ const items = [];
16923
+ for (const item of Array.from(transfer.items)) {
16924
+ if (item.kind !== 'file')
16925
+ continue;
16926
+ const file = item.getAsFile();
16927
+ if (file === null)
16928
+ continue;
16929
+ // Directory detection with the host's own default: no entry API
16930
+ // reads as "not a directory". Items without a File behind them never
16931
+ // reach the descriptors, so a plain-text paste arrives as an empty
16932
+ // list — the pure no-files passthrough.
16933
+ const entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
16934
+ files.push(file);
16935
+ items.push({
16936
+ entry: 'paste',
16937
+ isDirectory: entry?.isDirectory === true,
16938
+ hasPath: bridgePathFor(bridge, file) !== '',
16939
+ isImage: file.type.startsWith('image/'),
16940
+ });
16941
+ }
16942
+ const decision = (0, remote_drop_ts_1.decideRemoteIntake)({
16943
+ hasHostPaths: bridge !== undefined,
16944
+ currentSessionId: readCurrentSessionId(),
16945
+ trusted: event.isTrusted,
16946
+ items,
16947
+ });
16948
+ if (decision.action === 'block-directories') {
16949
+ // A folder paste cannot be fixed by single-file upload: same toast
16950
+ // semantics as the drop branch (no overlay to dismiss — paste never
16951
+ // opened one).
16952
+ event.preventDefault();
16953
+ event.stopPropagation();
16954
+ diagnostics.intercepted += 1;
16955
+ bump(diagnostics.interceptedBy, 'paste');
16956
+ diagnostics.blockedDirectories += 1;
16957
+ bump(diagnostics.blockedBy, 'paste');
16958
+ showToast();
16959
+ return;
16960
+ }
16961
+ if (decision.action !== 'redirect') {
16962
+ diagnostics.passthrough += 1;
16963
+ bump(diagnostics.passthroughBy, 'paste');
16964
+ return;
16965
+ }
16966
+ try {
16967
+ const payload = buildDropPayload(files);
16968
+ if (payload === undefined) {
16969
+ diagnostics.passthrough += 1;
16970
+ bump(diagnostics.passthroughBy, 'paste');
16971
+ return;
16972
+ }
16973
+ event.preventDefault();
16974
+ event.stopPropagation();
16975
+ diagnostics.intercepted += 1;
16976
+ bump(diagnostics.interceptedBy, 'paste');
16977
+ dispatchSyntheticDrop(payload);
16978
+ diagnostics.redispatched += 1;
16979
+ bump(diagnostics.redirectedBy, 'paste');
16980
+ }
16981
+ catch {
16982
+ diagnostics.rewrapFailed += 1;
16983
+ }
16984
+ };
16985
+ document.addEventListener('paste', onPaste, true);
16655
16986
  return () => {
16656
16987
  document.removeEventListener('drop', onDrop, true);
16988
+ document.removeEventListener('change', onChange, true);
16989
+ document.removeEventListener('paste', onPaste, true);
16657
16990
  removeToast();
16658
16991
  // Keep the counters readable after a dispose (the settings page may
16659
16992
  // still be open mid-reload); `installed` is the armed flag only.
@@ -16704,7 +17037,9 @@ const react_1 = require("react");
16704
17037
  const dsh_client_ui_primitives_1 = require("@deepseek-ai/dsh-client-ui-primitives");
16705
17038
  const settings_form_ts_1 = require("./client-data/settings-form.js");
16706
17039
  const plugin_bridge_ts_1 = require("./client-data/plugin-bridge.js");
16707
- const remote_drop_ts_1 = require("./client/effects/remote-drop.js");
17040
+ const current_session_ts_1 = require("./client-data/current-session.js");
17041
+ const remote_drop_ts_1 = require("./client-data/remote-drop.js");
17042
+ const remote_drop_ts_2 = require("./client/effects/remote-drop.js");
16708
17043
  const shares_ts_1 = require("./client-data/shares.js");
16709
17044
  /** Device roles the pairing picker offers, in display order. */
16710
17045
  const PAIR_ROLES = ['web', 'desktop-client'];
@@ -16730,6 +17065,45 @@ async function postJson(url, payload) {
16730
17065
  return { ok: false, sent: 0, failed: 0 };
16731
17066
  }
16732
17067
  }
17068
+ /** CU-4: the export's download name, `zen-remote-debug-<role>-<yyyymmdd-hhmm>.json`,
17069
+ * stamped in the browser's local time (the file is for the user's own
17070
+ * folder; the report's `_meta.generatedAt` carries the exact UTC instant). */
17071
+ function debugExportFileName(role, now) {
17072
+ const pad = (n) => String(n).padStart(2, '0');
17073
+ const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
17074
+ return `zen-remote-debug-${role}-${stamp}.json`;
17075
+ }
17076
+ /** CU-4: one transient notice for the export's outcome — the same inline-
17077
+ * styled overlay habit the remote-drop guard's folder toast uses (styles
17078
+ * stay inline: nothing of this goes into pwa/app.css or the style sheets),
17079
+ * one at a time, gone after 3 s, never intercepting taps. */
17080
+ let debugToast;
17081
+ let debugToastTimer;
17082
+ function showDebugToast(text) {
17083
+ if (debugToastTimer !== undefined)
17084
+ window.clearTimeout(debugToastTimer);
17085
+ debugToast?.remove();
17086
+ const el = document.createElement('div');
17087
+ el.dataset.zenRemote = 'debug-export-toast';
17088
+ el.textContent = text;
17089
+ el.style.cssText = [
17090
+ 'position:fixed', 'left:50%', 'transform:translateX(-50%)',
17091
+ 'bottom:calc(env(safe-area-inset-bottom, 0px) + 32px)',
17092
+ 'z-index:2147483000',
17093
+ 'background:rgba(0,0,0,.82)', 'color:#fff',
17094
+ 'font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
17095
+ 'padding:10px 16px', 'border-radius:10px', 'max-width:90vw', 'text-align:center',
17096
+ 'pointer-events:none',
17097
+ ].join(';');
17098
+ document.body.appendChild(el);
17099
+ debugToast = el;
17100
+ debugToastTimer = window.setTimeout(() => {
17101
+ el.remove();
17102
+ if (debugToast === el)
17103
+ debugToast = undefined;
17104
+ debugToastTimer = undefined;
17105
+ }, 3000);
17106
+ }
16733
17107
  function SettingsSection(props) {
16734
17108
  // The summary view is a one-liner only — it must not even fetch
16735
17109
  // admin/status, so every hook (and the fetch) lives in the page component.
@@ -16803,6 +17177,8 @@ function SettingsSectionPage({ config, shares, t }) {
16803
17177
  // JS dialog methods, so renames edit inline).
16804
17178
  const [renaming, setRenaming] = (0, react_1.useState)(null);
16805
17179
  const [pushTest, setPushTest] = (0, react_1.useState)({ state: 'idle' });
17180
+ // CU-4: the debug export's in-flight marker.
17181
+ const [debugBusy, setDebugBusy] = (0, react_1.useState)(false);
16806
17182
  // The client group's pairing form (T16). The address prefills from the row
16807
17183
  // (once the shared form's document is served) until the user types into it;
16808
17184
  // the code normalizes as typed. The registered name is the shared 设备名称
@@ -16900,7 +17276,7 @@ function SettingsSectionPage({ config, shares, t }) {
16900
17276
  // time — no snapshot state of its own: the initial mount, the 15s
16901
17277
  // connected-status poll and the countdown ticker all re-render this page,
16902
17278
  // and each render takes the values as they are right then.
16903
- const dropDiag = (0, remote_drop_ts_1.remoteDropDiagnostics)();
17279
+ const dropDiag = (0, remote_drop_ts_2.remoteDropDiagnostics)();
16904
17280
  const clientRetryAt = clientView?.nextRetryAt;
16905
17281
  const clientCountingDown = clientRetryAt !== undefined;
16906
17282
  // Offline with a pending retry: refresh the status shortly after the
@@ -17154,6 +17530,50 @@ function SettingsSectionPage({ config, shares, t }) {
17154
17530
  const result = await postJson(settings_form_ts_1.ADMIN_PUSH_TEST_ROUTE, {});
17155
17531
  setPushTest(result.ok ? { state: 'ok', sent: result.sent, failed: result.failed } : { state: 'fail' });
17156
17532
  };
17533
+ // CU-4 导出诊断信息: the backend's redacted report (both roles answer
17534
+ // their own), merged with the two facts only THIS page can read — the
17535
+ // attachment-entry guard's browser-side counters and whether the session
17536
+ // open right now is a remote one — then downloaded as one JSON file. The
17537
+ // browser plate carries counters and a boolean, nothing else: no session
17538
+ // id, no storage dump.
17539
+ const runDebugExport = async () => {
17540
+ setDebugBusy(true);
17541
+ try {
17542
+ const res = await fetch(settings_form_ts_1.CLIENT_DEBUG_REPORT_ROUTE);
17543
+ if (!res.ok)
17544
+ throw new StatusError(res.status);
17545
+ const report = await res.json();
17546
+ let remote = false;
17547
+ try {
17548
+ const current = (0, current_session_ts_1.parseCurrentSessionStorage)(window.localStorage.getItem(current_session_ts_1.CURRENT_SESSION_STORAGE_KEY));
17549
+ remote = current.kind === 'open' && (0, remote_drop_ts_1.isRemoteSessionId)(current.sessionId);
17550
+ }
17551
+ catch { /* storage unreadable: report not-remote */ }
17552
+ const merged = {
17553
+ ...report,
17554
+ browser: {
17555
+ remoteDrop: (0, remote_drop_ts_2.remoteDropDiagnostics)(),
17556
+ currentSession: { remote },
17557
+ },
17558
+ };
17559
+ const blob = new Blob([`${JSON.stringify(merged, null, 2)}\n`], { type: 'application/json' });
17560
+ const url = URL.createObjectURL(blob);
17561
+ const anchor = document.createElement('a');
17562
+ anchor.href = url;
17563
+ anchor.download = debugExportFileName(clientRole ? 'client' : 'host', new Date());
17564
+ document.body.appendChild(anchor);
17565
+ anchor.click();
17566
+ anchor.remove();
17567
+ // Revoked on the next tick: some engines start the download only
17568
+ // after the click handler returns.
17569
+ window.setTimeout(() => { URL.revokeObjectURL(url); }, 0);
17570
+ showDebugToast(t('settings.debug.done'));
17571
+ }
17572
+ catch {
17573
+ showDebugToast(t('settings.debug.fail'));
17574
+ }
17575
+ setDebugBusy(false);
17576
+ };
17157
17577
  // --- client group (T16) ----------------------------------------------------
17158
17578
  // The address prefill reads the row's stored serverUrl while the user has
17159
17579
  // not typed anything of their own.
@@ -17371,7 +17791,14 @@ function SettingsSectionPage({ config, shares, t }) {
17371
17791
  passthrough: dropDiag.passthrough,
17372
17792
  failed: dropDiag.rewrapFailed,
17373
17793
  })
17374
- : t('settings.client.diagRemoteDropOff', { reason: dropDiag.notInstalledReason ?? '—' }) })] })), clientView?.uploadProbe !== undefined && (() => {
17794
+ : t('settings.client.diagRemoteDropOff', { reason: dropDiag.notInstalledReason ?? '—' }) }), dropDiag.installed && ((0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", children: t('settings.client.diagRemoteDropEntries', {
17795
+ dropI: dropDiag.interceptedBy.drop,
17796
+ pickerI: dropDiag.interceptedBy.picker,
17797
+ pasteI: dropDiag.interceptedBy.paste,
17798
+ dropP: dropDiag.passthroughBy.drop,
17799
+ pickerP: dropDiag.passthroughBy.picker,
17800
+ pasteP: dropDiag.passthroughBy.paste,
17801
+ }) }))] })), clientView?.uploadProbe !== undefined && (() => {
17375
17802
  const probe = clientView.uploadProbe;
17376
17803
  const seen = probe.uploadInjectSeen === true;
17377
17804
  const refused = seen && probe.fetchRoute?.shape?.reasons instanceof Array && probe.fetchRoute.shape.reasons.length > 0;
@@ -17387,7 +17814,7 @@ function SettingsSectionPage({ config, shares, t }) {
17387
17814
  return ((0, jsx_runtime_1.jsxs)("div", { className: "zr-settings-field", children: [(0, jsx_runtime_1.jsx)("div", { className: "zr-settings-head", children: (0, jsx_runtime_1.jsx)("label", { children: t('settings.client.diagUploadRedirect') }) }), (0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", children: text })] }));
17388
17815
  })(), clientView.compat !== undefined && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [clientView.compat.mismatchedGroups.length > 0 && ((0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", "data-invalid": "true", children: t('settings.client.diagCompatGroups', { groups: clientView.compat.mismatchedGroups.join(', ') }) })), clientView.compat.recentCalls.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "zr-settings-field", children: [(0, jsx_runtime_1.jsx)("div", { className: "zr-settings-head", children: (0, jsx_runtime_1.jsx)("label", { children: t('settings.client.diagCompatCalls') }) }), clientView.compat.recentCalls.map((failure, index) => ((0, jsx_runtime_1.jsxs)("p", { className: "zr-settings-hint", children: [new Date(failure.time).toLocaleString(), " \u00B7 ", failure.method !== '' ? failure.method : '—', " \u00B7 ", failure.code] }, index)))] }))] })), (clientView.pluginBridge?.length ?? 0) > 0 && clientView.pluginBridge !== undefined && ((0, jsx_runtime_1.jsxs)("div", { className: "zr-settings-field", children: [(0, jsx_runtime_1.jsx)("div", { className: "zr-settings-head", children: (0, jsx_runtime_1.jsx)("label", { children: t('settings.bridge.title') }) }), clientView.pluginBridge.map((row) => ((0, jsx_runtime_1.jsxs)("p", { className: "zr-settings-hint", children: [row.plugin, " \u00B7 ", row.localVersion !== '' ? `v${row.localVersion}` : '—', " \u00B7", ' ', row.stateKey === 'settings.bridge.stateVersionMismatch'
17389
17816
  ? t(row.stateKey, { version: row.serverVersion ?? '?' })
17390
- : t(row.stateKey)] }, row.plugin)))] })), clientView.pluginRouteIntercept !== undefined && !clientView.pluginRouteIntercept.installed && ((0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", "data-invalid": "true", children: t('settings.bridge.interceptOff', { reason: clientView.pluginRouteIntercept.reasons.join('; ') || '—' }) }))] }))] }))] }));
17817
+ : t(row.stateKey)] }, row.plugin)))] })), clientView.pluginRouteIntercept !== undefined && !clientView.pluginRouteIntercept.installed && ((0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", "data-invalid": "true", children: t('settings.bridge.interceptOff', { reason: clientView.pluginRouteIntercept.reasons.join('; ') || '—' }) }))] }))] })), (0, jsx_runtime_1.jsx)("div", { className: "zr-settings-card", children: (0, jsx_runtime_1.jsxs)("div", { className: "zr-settings-field", children: [(0, jsx_runtime_1.jsxs)("div", { className: "zr-settings-head", children: [(0, jsx_runtime_1.jsx)("span", { className: "zr-settings-head-title", children: t('settings.debug.title') }), (0, jsx_runtime_1.jsx)("span", { style: { flex: 1 } }), (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { variant: "outline", size: "sm", disabled: debugBusy, onClick: () => { void runDebugExport(); }, children: debugBusy ? t('settings.debug.exporting') : t('settings.debug.export') })] }), (0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", children: t('settings.debug.hint') })] }) })] }));
17391
17818
  }
17392
17819
  /** Display name of a value's source layer for the invalid-saved-value note. */
17393
17820
  function sourceName(source, t) {