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.
- package/AGENTS.md +15 -5
- package/CHANGELOG.md +71 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/docs/interface.md +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/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/package.json +3 -2
- 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/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
|
|
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': '
|
|
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
|
-
*
|
|
16395
|
-
*
|
|
16396
|
-
*
|
|
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
|
-
*
|
|
16399
|
-
*
|
|
16400
|
-
*
|
|
16401
|
-
* `
|
|
16402
|
-
*
|
|
16403
|
-
*
|
|
16404
|
-
*
|
|
16405
|
-
*
|
|
16406
|
-
*
|
|
16407
|
-
|
|
16408
|
-
|
|
16409
|
-
|
|
16410
|
-
|
|
16411
|
-
|
|
16412
|
-
|
|
16413
|
-
|
|
16414
|
-
|
|
16415
|
-
|
|
16416
|
-
|
|
16417
|
-
|
|
16418
|
-
|
|
16419
|
-
|
|
16420
|
-
|
|
16421
|
-
|
|
16422
|
-
|
|
16423
|
-
|
|
16424
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
16631
|
-
if (
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 ?? '—' }) })
|
|
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) {
|