dsh-zen-remote 2.3.5 → 2.3.7

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