dsh-zen-remote 2.2.5 → 2.2.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 (103) hide show
  1. package/AGENTS.md +15 -3
  2. package/README.md +10 -2
  3. package/README.zh-CN.md +10 -2
  4. package/docs/interface.md +17 -0
  5. package/docs/remote-access.en.md +9 -2
  6. package/docs/remote-access.md +9 -2
  7. package/lib/adapters/dsh-better-sidebar/index.js +1 -1
  8. package/lib/client-routes.js +3 -0
  9. package/lib/client-routes.js.map +1 -1
  10. package/lib/client.js +1189 -203
  11. package/lib/fetch-route-intercept.js +1 -1
  12. package/lib/index.js +73 -16
  13. package/lib/index.js.map +1 -1
  14. package/lib/intercept.js +13 -0
  15. package/lib/intercept.js.map +1 -1
  16. package/lib/invoke-binary.js +8 -2
  17. package/lib/invoke-binary.js.map +1 -1
  18. package/lib/lan-gate-server.cjs +109 -2
  19. package/lib/owner-peer.js +23 -0
  20. package/lib/owner-peer.js.map +1 -1
  21. package/lib/plugin-adapter-core.js +2 -2
  22. package/lib/relay-access.js +24 -2
  23. package/lib/relay-access.js.map +1 -1
  24. package/lib/relay-client.js +2 -2
  25. package/lib/relay-server.js +103 -100
  26. package/lib/relay-server.js.map +1 -1
  27. package/lib/types/client/MobileAttachButton.d.ts +5 -2
  28. package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
  29. package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
  30. package/lib/types/client/MobileHome.d.ts.map +1 -1
  31. package/lib/types/client/RemoteFileDownload.d.ts +57 -0
  32. package/lib/types/client/RemoteFileDownload.d.ts.map +1 -0
  33. package/lib/types/client/attach-upload.d.ts +32 -0
  34. package/lib/types/client/attach-upload.d.ts.map +1 -1
  35. package/lib/types/client/effects/remote-drop.d.ts +27 -0
  36. package/lib/types/client/effects/remote-drop.d.ts.map +1 -0
  37. package/lib/types/client/index.d.ts.map +1 -1
  38. package/lib/types/client/locales.d.ts +11 -0
  39. package/lib/types/client/locales.d.ts.map +1 -1
  40. package/lib/types/client/remote-file-save.d.ts +112 -0
  41. package/lib/types/client/remote-file-save.d.ts.map +1 -0
  42. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  43. package/lib/types/client/styles/compat.css.d.ts +1 -1
  44. package/lib/types/client/styles/compat.css.d.ts.map +1 -1
  45. package/lib/types/client/styles/header.css.d.ts +1 -1
  46. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  47. package/lib/types/client/styles/home.css.d.ts +1 -1
  48. package/lib/types/client/styles/home.css.d.ts.map +1 -1
  49. package/lib/types/client/styles/layout.css.d.ts +1 -1
  50. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  51. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  52. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  53. package/lib/types/client-data/remote-drop.d.ts +99 -0
  54. package/lib/types/client-data/remote-drop.d.ts.map +1 -0
  55. package/lib/types/client-data/settings-form.d.ts +7 -0
  56. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  57. package/lib/types/client-routes.d.ts +6 -0
  58. package/lib/types/client-routes.d.ts.map +1 -1
  59. package/lib/types/index.d.ts.map +1 -1
  60. package/lib/types/intercept.d.ts.map +1 -1
  61. package/lib/types/invoke-binary.d.ts +4 -1
  62. package/lib/types/invoke-binary.d.ts.map +1 -1
  63. package/lib/types/owner-peer.d.ts +9 -0
  64. package/lib/types/owner-peer.d.ts.map +1 -1
  65. package/lib/types/plugin-adapter-core.d.ts +1 -1
  66. package/lib/types/relay-access.d.ts.map +1 -1
  67. package/lib/types/relay-server.d.ts +10 -7
  68. package/lib/types/relay-server.d.ts.map +1 -1
  69. package/package.json +36 -36
  70. package/pwa/inject.js +20 -9
  71. package/pwa/sw.js +97 -21
  72. package/scripts/check-attach-upload.mjs +37 -0
  73. package/scripts/check-remote-drop.mjs +114 -0
  74. package/scripts/check-settings-form.mjs +3 -2
  75. package/scripts/check-upload-endpoint.mjs +14 -3
  76. package/src/adapters/dsh-better-sidebar/index.ts +1 -1
  77. package/src/client/MobileAttachButton.tsx +14 -9
  78. package/src/client/MobileAttachChips.tsx +4 -0
  79. package/src/client/MobileHome.tsx +79 -12
  80. package/src/client/RemoteFileDownload.tsx +173 -0
  81. package/src/client/attach-upload.ts +47 -0
  82. package/src/client/effects/remote-drop.ts +269 -0
  83. package/src/client/index.tsx +47 -0
  84. package/src/client/locales.ts +32 -0
  85. package/src/client/remote-file-save.ts +232 -0
  86. package/src/client/settings/SettingsSection.tsx +61 -1
  87. package/src/client/styles/compat.css.ts +43 -12
  88. package/src/client/styles/header.css.ts +63 -15
  89. package/src/client/styles/home.css.ts +64 -11
  90. package/src/client/styles/layout.css.ts +24 -7
  91. package/src/client/styles/remote-session.css.ts +38 -0
  92. package/src/client-data/remote-drop.ts +127 -0
  93. package/src/client-data/settings-form.ts +10 -0
  94. package/src/client-routes.ts +8 -0
  95. package/src/fetch-route-intercept.ts +1 -1
  96. package/src/index.ts +71 -18
  97. package/src/intercept.ts +13 -0
  98. package/src/invoke-binary.ts +8 -2
  99. package/src/owner-peer.ts +32 -0
  100. package/src/plugin-adapter-core.ts +2 -2
  101. package/src/relay-access.ts +24 -2
  102. package/src/relay-client.ts +2 -2
  103. package/src/relay-server.ts +114 -90
@@ -0,0 +1,269 @@
1
+ import type { ClientContext } from '../compat/types.ts'
2
+ import { decideRemoteDrop, rewrapFiles } from '../../client-data/remote-drop.ts'
3
+ import type { RemoteDropItemDescriptor } from '../../client-data/remote-drop.ts'
4
+ import { CURRENT_SESSION_STORAGE_KEY, parseCurrentSessionStorage } from '../../client-data/current-session.ts'
5
+ import { en, zh } from '../locales.ts'
6
+
7
+ /**
8
+ * The remote-session drop guard (RD-2): a document-level CAPTURE-phase drop
9
+ * listener that re-shapes file drops landing in a remote session before the
10
+ * host's own (bubble-phase, F4) handler sees them.
11
+ *
12
+ * The desktop app's zero-copy fast path is the problem: a File dragged from
13
+ * the OS keeps its disk path, the host turns it into an `@/absolute/path`
14
+ * mention chip without uploading bytes, and in a remote session that path
15
+ * names a file on THIS machine — the server-side session can never read it.
16
+ * The bridge global (`__DSH_HOST_PATHS__`) is contextBridge-exposed and
17
+ * deep-frozen, so it cannot be wrapped; instead the drop event itself is
18
+ * re-shaped: each File is re-wrapped (path-stripped, RD-1) into a fresh
19
+ * DataTransfer and redispatched as a synthetic DragEvent. The host's own
20
+ * pipeline then classifies it as a browser drag — the byte-upload branch —
21
+ * which the T51 fetch-route interception relays to the server. Mask
22
+ * cleanup, busy rejection, directory detection and the addFiles branch all
23
+ * stay the host's own code; nothing downstream is reimplemented here.
24
+ *
25
+ * Gates (all three must pass, decided by RD-1's pure function): the event
26
+ * is trusted (our redispatch is untrusted — that alone makes the guard
27
+ * loop-free), the Electron bridge exists (phone / plain-browser hosts are
28
+ * already on the upload branch), and the current session (`dsh.sessions.
29
+ * current`, read fresh at event time) is a `zr~` virtual id. A missing
30
+ * signal counts as a local session — the conservative direction: the only
31
+ * misfire a stale signal can cause is uploading bytes for a local drop,
32
+ * never losing one.
33
+ *
34
+ * Degrades to not-installed when DragEvent / DataTransfer construction is
35
+ * unavailable — back to exactly today's behavior (RD-4's interface.md
36
+ * documents this). Diagnostic counters for the settings-page diagnostics
37
+ * block are exported via {@link remoteDropDiagnostics}.
38
+ */
39
+
40
+ interface StorageLike {
41
+ getItem(key: string): string | null
42
+ }
43
+
44
+ export interface RemoteDropDiagnostics {
45
+ /** The listener is mounted and armed (capability check passed, effect
46
+ * not yet disposed). */
47
+ installed: boolean
48
+ /** Why the capability check declined to install; `undefined` while
49
+ * installed (and after a dispose, which keeps the counters readable). */
50
+ notInstalledReason: string | undefined
51
+ /** Drops this guard stopped from reaching the host (both the blocked and
52
+ * the redispatched kind). */
53
+ intercepted: number
54
+ /** Synthetic path-stripped drops actually dispatched. */
55
+ redispatched: number
56
+ /** Drops stopped because they carried a folder (toast shown, nothing
57
+ * dispatched). */
58
+ blockedDirectories: number
59
+ /** Drops judged not-ours and released untouched. */
60
+ passthrough: number
61
+ /** Redispatches aborted because a re-wrap or a DataTransfer add threw —
62
+ * the original event was released instead (宁坏勿丢). */
63
+ rewrapFailed: number
64
+ }
65
+
66
+ /** Module-level singleton: the bundle has one document, so one guard state.
67
+ * Re-mounting (a plugin row reload) resets it. */
68
+ let diagnostics: RemoteDropDiagnostics = freshDiagnostics()
69
+
70
+ function freshDiagnostics(): RemoteDropDiagnostics {
71
+ return { installed: false, notInstalledReason: undefined, intercepted: 0, redispatched: 0, blockedDirectories: 0, passthrough: 0, rewrapFailed: 0 }
72
+ }
73
+
74
+ /** A snapshot for the diagnostics carriers (settings page). Copying keeps
75
+ * callers from holding a live reference into a later mount's state. */
76
+ export function remoteDropDiagnostics(): RemoteDropDiagnostics {
77
+ return { ...diagnostics }
78
+ }
79
+
80
+ /** The contextBridge global's existence only — never touched otherwise
81
+ * (it is deep-frozen and must stay unwrapped). */
82
+ function hostPathsOf(): unknown {
83
+ return (globalThis as { __DSH_HOST_PATHS__?: unknown }).__DSH_HOST_PATHS__
84
+ }
85
+
86
+ /** The current-session signal, read AT EVENT TIME (the selection changes
87
+ * while the page is open; a mount-time snapshot would go stale). Any
88
+ * storage failure reads as "no signal" = local session = passthrough. */
89
+ function readCurrentSessionId(): string | undefined {
90
+ let raw: string | null
91
+ try {
92
+ const storage = (globalThis as { localStorage?: StorageLike | null }).localStorage
93
+ raw = storage === null || storage === undefined ? null : storage.getItem(CURRENT_SESSION_STORAGE_KEY)
94
+ } catch {
95
+ return undefined
96
+ }
97
+ const report = parseCurrentSessionStorage(raw)
98
+ return report.kind === 'open' ? report.sessionId : undefined
99
+ }
100
+
101
+ /** Reduce the drop's items to RD-1's descriptors. `undefined` when the
102
+ * entry API is missing entirely — directories cannot be detected, and the
103
+ * host's own semantics treat that as "no directories". Directory entries
104
+ * surface as file-kind items (their bytes are unreadable), which is what
105
+ * the block-directories branch keys on. */
106
+ function collectItems(transfer: DataTransfer): RemoteDropItemDescriptor[] | undefined {
107
+ const out: RemoteDropItemDescriptor[] = []
108
+ for (const item of Array.from(transfer.items)) {
109
+ if (item.kind !== 'file') continue
110
+ if (typeof item.webkitGetAsEntry !== 'function') return undefined
111
+ const entry = item.webkitGetAsEntry()
112
+ out.push({ kind: 'file', isDirectory: entry?.isDirectory === true })
113
+ }
114
+ return out
115
+ }
116
+
117
+ /** The toast copy in the page's language (the plugin's namespace carries
118
+ * both dictionaries; the framework `t` seat is not reachable from a
119
+ * non-React effect — same pick as the bridge note). */
120
+ function toastText(language: string | undefined): string {
121
+ const key = 'remoteDropFolderBlocked' as const
122
+ return typeof language === 'string' && language.startsWith('zh') ? zh[key] : en[key]
123
+ }
124
+
125
+ /** Take the host's「拖拽文件到此处添加」drop overlay back down after this
126
+ * guard has stopped a drop outright. The overlay's own state machine
127
+ * (`dsh-client-ui-attachment` drop-events.ts) closes on the reset() inside
128
+ * its DOCUMENT-level bubble `drop` handler — which this guard's capture
129
+ * stopPropagation deliberately prevents from running — or on `dragend` at
130
+ * `window` level, a channel it reads without isTrust-checking. An OS-file
131
+ * drag never delivers a native dragend (the drag source is the OS, not a
132
+ * page element), so a branch that eats the drop must fire a synthetic one
133
+ * itself or the overlay stays up forever. Only the folder-block branch
134
+ * calls this: the redispatch branch's synthetic drop reaches the host's
135
+ * bubble handler, whose own reset brings the overlay down. */
136
+ function dismissHostDropOverlay(): void {
137
+ try {
138
+ window.dispatchEvent(new Event('dragend'))
139
+ } catch {
140
+ // No window / Event in this host: the overlay is the only casualty.
141
+ }
142
+ }
143
+
144
+ export function mountRemoteDropGuard(ctx: ClientContext): void {
145
+ ctx.effect(() => {
146
+ // Fresh state per mount: a plugin reload re-runs apply() and must not
147
+ // inherit the previous mount's counters or installed flag.
148
+ diagnostics = freshDiagnostics()
149
+
150
+ // Capability self-check — anything missing means "not installed",
151
+ // back to today's behavior. Counted, not thrown: the diagnostics
152
+ // block explains why the guard is absent.
153
+ if (typeof DragEvent !== 'function') {
154
+ diagnostics.notInstalledReason = 'drag-event-unavailable'
155
+ return () => {}
156
+ }
157
+ try {
158
+ new DataTransfer()
159
+ } catch {
160
+ diagnostics.notInstalledReason = 'datatransfer-unavailable'
161
+ return () => {}
162
+ }
163
+ if (hostPathsOf() === undefined) {
164
+ // Phone / plain-browser hosts: the bridge is absent and the host is
165
+ // already on the byte-upload branch — nothing to fix here.
166
+ diagnostics.notInstalledReason = 'host-paths-unavailable'
167
+ return () => {}
168
+ }
169
+ diagnostics.installed = true
170
+
171
+ // --- toast (folder-blocked notice), inline styles per the CSS split ---
172
+ let toast: HTMLDivElement | undefined
173
+ let toastTimer: ReturnType<typeof setTimeout> | undefined
174
+ const removeToast = (): void => {
175
+ if (toastTimer !== undefined) clearTimeout(toastTimer)
176
+ toastTimer = undefined
177
+ toast?.remove()
178
+ toast = undefined
179
+ }
180
+ const showToast = (): void => {
181
+ // Restart the window on rapid re-drops rather than stacking toasts.
182
+ removeToast()
183
+ const el = document.createElement('div')
184
+ el.dataset.zenRemote = 'remote-drop-toast'
185
+ el.textContent = toastText(navigator.language)
186
+ el.style.cssText = [
187
+ 'position:fixed', 'left:50%', 'transform:translateX(-50%)',
188
+ // Safe-area habit shared with the overlay effects: keep clear of the
189
+ // home indicator, degrade to a plain 32px offset where env() is 0.
190
+ 'bottom:calc(env(safe-area-inset-bottom, 0px) + 32px)',
191
+ 'z-index:2147483000',
192
+ 'background:rgba(0,0,0,.82)', 'color:#fff',
193
+ 'font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
194
+ 'padding:10px 16px', 'border-radius:10px', 'max-width:90vw', 'text-align:center',
195
+ // Purely informational: never intercepts the taps under it.
196
+ 'pointer-events:none',
197
+ ].join(';')
198
+ document.body.appendChild(el)
199
+ toast = el
200
+ toastTimer = setTimeout(removeToast, 3000)
201
+ }
202
+
203
+ const onDrop = (event: DragEvent): void => {
204
+ // Self-reentry guard first: the redispatched event is untrusted, and
205
+ // this capture listener would otherwise see it again.
206
+ if (!event.isTrusted) return
207
+ const transfer = event.dataTransfer
208
+ if (transfer === null || !transfer.types.includes('Files')) return
209
+ // Not a file drag: the host's own Files-in-types gate ignores the
210
+ // drop anyway — not a candidate, not counted.
211
+ const decision = decideRemoteDrop({
212
+ hasHostPaths: hostPathsOf() !== undefined,
213
+ currentSessionId: readCurrentSessionId(),
214
+ trusted: event.isTrusted,
215
+ items: collectItems(transfer),
216
+ })
217
+ if (decision.action === 'passthrough') {
218
+ diagnostics.passthrough += 1
219
+ return
220
+ }
221
+ if (decision.action === 'block-directories') {
222
+ // A folder cannot be fixed by single-file upload: stop the whole
223
+ // drop, say so, nothing is dispatched.
224
+ event.preventDefault()
225
+ event.stopPropagation()
226
+ dismissHostDropOverlay()
227
+ diagnostics.intercepted += 1
228
+ diagnostics.blockedDirectories += 1
229
+ showToast()
230
+ return
231
+ }
232
+ // redispatch: build the ENTIRE payload before stopping the original
233
+ // event — any failure below releases the original untouched, so the
234
+ // host still receives today's (path-chip) behavior instead of
235
+ // nothing at all.
236
+ try {
237
+ const rewrapped = rewrapFiles(Array.from(transfer.files))
238
+ if (rewrapped.length === 0) {
239
+ diagnostics.passthrough += 1
240
+ return
241
+ }
242
+ const payload = new DataTransfer()
243
+ for (const file of rewrapped) payload.items.add(file)
244
+ event.preventDefault()
245
+ event.stopPropagation()
246
+ diagnostics.intercepted += 1
247
+ // The host's drop handler sits on document, bubble phase (F4): a
248
+ // bubbling synthetic drop on document reaches it. Its own handler
249
+ // does the preventDefault / mask reset / addFiles work; our capture
250
+ // listener skips this event via the isTrusted gate above.
251
+ document.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: payload }))
252
+ diagnostics.redispatched += 1
253
+ } catch {
254
+ // A re-wrap or a single items.add threw (0-byte / exotic File):
255
+ // count it and let the original event flow — 宁坏勿丢.
256
+ diagnostics.rewrapFailed += 1
257
+ }
258
+ }
259
+ document.addEventListener('drop', onDrop, true)
260
+
261
+ return () => {
262
+ document.removeEventListener('drop', onDrop, true)
263
+ removeToast()
264
+ // Keep the counters readable after a dispose (the settings page may
265
+ // still be open mid-reload); `installed` is the armed flag only.
266
+ diagnostics.installed = false
267
+ }
268
+ }, 'dsh-zen-remote: remote drop guard')
269
+ }
@@ -37,10 +37,13 @@ import { RemoteStatusIcon } from './RemoteStatusIcon.tsx'
37
37
  import { RemoteComposerBanner } from './RemoteComposerBanner.tsx'
38
38
  import { registerRemoteStatusUi } from './remote-status-register.ts'
39
39
  import { mountCurrentSessionReporter } from './current-session-report.ts'
40
+ import { mountRemoteDropGuard } from './effects/remote-drop.ts'
40
41
  import { mountRemoteGroupExpansionKeeper } from './remote-group-expansion.ts'
41
42
  import { installRemoteApiFetch } from './remote-fetch.ts'
42
43
  import { installRemoteSessionGuard } from './effects/remote-session.ts'
43
44
  import { installModelGroupSide } from './effects/model-group-side.ts'
45
+ import { DeliverableDownload, DocumentDownload } from './RemoteFileDownload.tsx'
46
+ import { createSaveRemoteFile } from './remote-file-save.ts'
44
47
 
45
48
  declare module '@deepseek-ai/dsh-client-ui-slots' {
46
49
  interface LocaleNamespaceMap {
@@ -135,6 +138,15 @@ export function apply(ctx: ClientContext): void {
135
138
  // 的桌面端 App 正是远程会话所在;只在探测出的子客户端角色下运行,主
136
139
  // 服务端的页面探测一次后就静默。
137
140
  mountCurrentSessionReporter(ctx)
141
+ // 远程会话拖拽附件(RD-2):document 级 capture 阶段的 drop 拦截。桌面端
142
+ // 的同机零拷贝会把拖入的文件变成 `@本机绝对路径` chip,远程会话里服务端
143
+ // 读不到这个路径——守卫把远程会话的文件拖拽改写成无路径 File 的合成
144
+ // drop(宿主原生管道按浏览器拖拽走字节上传,再由 T51 的 fetch-route
145
+ // 拦截经中继转服务端)。判定在 client-data/remote-drop.ts(纯函数),
146
+ // 这里只做监听与重派发。同样必须在桌面门之前挂载:远程会话恰在桌面端
147
+ // 全宽使用;三门全不中(本地会话 / 手机端无 bridge / 非受信事件)时
148
+ // 零干预,主服务端的页面不受影响。
149
+ mountRemoteDropGuard(ctx)
138
150
  // T64 服务端分组展开状态的记忆与恢复:宿主在工作区列表 ready 时会把不在
139
151
  // 列表里的分组展开记录清掉(retainAccountKeys),而本地工作区先 ready、
140
152
  // 服务端分组后并入,zr~ 键每次加载都被清掉、分组回到折叠。与上报器共用
@@ -156,6 +168,41 @@ export function apply(ctx: ClientContext): void {
156
168
  // 桌面端子客户端的模型选单一样要分侧,桌面无手机外壳不影响它。
157
169
  installModelGroupSide(ctx)
158
170
 
171
+ // 2.2.6 远程会话产物下载:子客户端最常见的诉求是「把服务端工作区里刚产出的
172
+ // 文件拿到本机」。DSH 官方的产物卡片只有预览,打开类动作(present.open /
173
+ // open-in-app)作用于跑 DSH 的那台机器,远程会话里被样式表整组隐藏——
174
+ // 也就是说此前客户端根本没有把文件落到本机磁盘的入口。两个入口共用同一个
175
+ // 「读中继 + 浏览器下载」实现(RemoteFileDownload.tsx + remote-file-save.ts):
176
+ // deliverable 卡片上的按钮从卡片自己的预览按钮 title 取绝对路径,
177
+ // 文档预览头的按钮直接拿 owner 给的 absolutePath;两者都只在虚拟会话渲染,
178
+ // 主服务端与本地会话逐像素 no-op。同样在桌面门之前注册——client 角色的
179
+ // 桌面端 App 正是远程会话所在。
180
+ const saveRemoteFileToLocal = createSaveRemoteFile(ctx)
181
+ ctx.slots.inject('deliverables.file.actions', () =>
182
+ ctx.slots.register(
183
+ {
184
+ name: 'deliverables.file.actions',
185
+ id: 'zr-download',
186
+ order: 40,
187
+ locale: NS,
188
+ inject: () => ({ saveFile: saveRemoteFileToLocal }),
189
+ },
190
+ DeliverableDownload,
191
+ ),
192
+ )
193
+ ctx.slots.inject('sidebar.right.tab.document.actions', () =>
194
+ ctx.slots.register(
195
+ {
196
+ name: 'sidebar.right.tab.document.actions',
197
+ id: 'zr-download',
198
+ order: 40,
199
+ locale: NS,
200
+ inject: () => ({ saveFile: saveRemoteFileToLocal }),
201
+ },
202
+ DocumentDownload,
203
+ ),
204
+ )
205
+
159
206
  // Desktop gate (DSH 0.1.7): the official Electron shell can be dragged
160
207
  // down to ~520px wide, where every width-based gate would flip the phone
161
208
  // shell on inside the desktop app. Inside that shell this plugin is a
@@ -253,6 +253,15 @@ export const zh = {
253
253
  'settings.client.diagRecentFailures': '最近的远程调用失败',
254
254
  'settings.client.diagCompatGroups': '指纹有差异的组:{groups}',
255
255
  'settings.client.diagCompatCalls': '最近的不兼容调用',
256
+ // --- RD-3 远程会话拖拽守卫的诊断行(浏览器端计数,渲染时现读) ---
257
+ 'settings.client.diagRemoteDrop': '远程拖拽守卫',
258
+ 'settings.client.diagRemoteDropOk': '已安装 · 拦截 {intercepted} · 重派发 {redispatched} · 文件夹 {blocked} · 放行 {passthrough} · 重包失败 {failed}',
259
+ 'settings.client.diagRemoteDropOff': '未安装({reason})',
260
+ // --- RD-6 上传改道(fetch-route 拦截)的诊断行:读 remote-status 的 serviceProbe ---
261
+ 'settings.client.diagUploadRedirect': '上传改道',
262
+ 'settings.client.diagUploadRedirectOk': '已安装 · 上传已接管',
263
+ 'settings.client.diagUploadRedirectRefused': '拒装:{reason}',
264
+ 'settings.client.diagUploadRedirectPending': '服务注入未触发 · connection {connection} · fileUploads {fileUploads}',
256
265
  // --- PB-5 插件桥:主服务端同意小节 + 子客户端诊断小节 ---
257
266
  'settings.bridge.title': '插件桥',
258
267
  'settings.bridge.empty': '暂无插件登记插件桥',
@@ -294,6 +303,12 @@ export const zh = {
294
303
  'remoteGroupRevoked': '(令牌已吊销)',
295
304
  'remoteGroupUnpaired': '(已解除配对)',
296
305
  'remoteGroupMismatch': '(版本有差异)',
306
+ // --- 远程会话拖拽附件(RD-2):文件夹被拦截时的短暂提示 ---
307
+ 'remoteDropFolderBlocked': '远程会话暂不支持拖入文件夹',
308
+ // --- 2.2.6: 远程会话产物下载(deliverable 卡片 / 文档预览头) ---
309
+ 'downloadToLocal': '下载到本机',
310
+ 'downloadBusy': '下载中…',
311
+ 'downloadFailed': '下载失败',
297
312
  } as const
298
313
 
299
314
  /** English dictionary, key-identical to the Chinese source of truth. */
@@ -543,6 +558,16 @@ export const en: Record<MobileNavKey, string> = {
543
558
  'settings.client.diagRecentFailures': 'Recent remote call failures',
544
559
  'settings.client.diagCompatGroups': 'Groups with fingerprint differences: {groups}',
545
560
  'settings.client.diagCompatCalls': 'Recent incompatible calls',
561
+ // --- RD-3: the remote drop guard's diagnostics line (browser-side
562
+ // counters, read at render time) ---
563
+ 'settings.client.diagRemoteDrop': 'Remote drop guard',
564
+ 'settings.client.diagRemoteDropOk': 'Installed · intercepted {intercepted} · redispatched {redispatched} · folders {blocked} · passthrough {passthrough} · rewrap failed {failed}',
565
+ 'settings.client.diagRemoteDropOff': 'Not installed ({reason})',
566
+ // --- RD-6: the upload-redirect (fetch-route intercept) diagnostics row ---
567
+ 'settings.client.diagUploadRedirect': 'Upload redirect',
568
+ 'settings.client.diagUploadRedirectOk': 'Installed · upload wrapped',
569
+ 'settings.client.diagUploadRedirectRefused': 'Refused: {reason}',
570
+ 'settings.client.diagUploadRedirectPending': 'Service inject never fired · connection {connection} · fileUploads {fileUploads}',
546
571
  // --- PB-5 plugin bridge: host consent section + sub-client diagnostics ---
547
572
  'settings.bridge.title': 'Plugin bridge',
548
573
  'settings.bridge.empty': 'No plugin has registered a bridge yet',
@@ -583,6 +608,13 @@ export const en: Record<MobileNavKey, string> = {
583
608
  'remoteGroupRevoked': ' (token revoked)',
584
609
  'remoteGroupUnpaired': ' (unpaired)',
585
610
  'remoteGroupMismatch': ' (version mismatch)',
611
+ // --- RD-2: the short toast when a folder drop into a remote session is blocked ---
612
+ 'remoteDropFolderBlocked': 'Folders cannot be dropped into a remote session yet',
613
+ // 2.2.6: remote-session artifact download (deliverable cards / document
614
+ // preview header).
615
+ 'downloadToLocal': 'Download to this computer',
616
+ 'downloadBusy': 'Downloading…',
617
+ 'downloadFailed': 'Download failed',
586
618
  }
587
619
 
588
620
  /** Key domain of the `mobileNav` namespace (zh is the source of truth). */
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Saving one workspace file onto THIS machine (2.2.6, remote artifact
3
+ * download).
4
+ *
5
+ * DSH has no download surface at all: a produced file's card only PREVIEWS it
6
+ * through the document preview (`workspaceFiles/readBytes`), and the
7
+ * native-open controls (`present.open`, `changes.open`,
8
+ * `open-in-app`) act on the machine the DSH process runs on — which is why
9
+ * the remote-session stylesheet hides them. So on a paired desktop
10
+ * sub-client, a file the HOST produced in its workspace had no way to reach
11
+ * the client's disk. This module is the missing read+save half:
12
+ *
13
+ * - bytes come from the SAME relayed `workspaceFiles/readBytes` the preview
14
+ * already uses (relay-access.ts registers the method; invoke-binary.ts
15
+ * carries its `data` Uint8Array across the JSON channel),
16
+ * - they come in byte windows because the host's read window is capped
17
+ * (`dsh-api-workspace-files` Config.maxBytes, 2 MiB by default): a full
18
+ * read without a range is capped separately at `maxFileBytes` (32 MiB), so
19
+ * ranged reads are the only shape that works for ANY produced file. A
20
+ * `workspace-file/too-large` refusal carries the host's own limit in
21
+ * `details.limit`, so the window adapts down instead of failing,
22
+ * - the assembled bytes become a Blob and a synthetic `<a download>` click,
23
+ * the one save path a browser/webview gives a page without asking the
24
+ * desktop shell for a native dialog.
25
+ *
26
+ * The pure half (`collectRemoteFile`, `downloadFileNameOf`) takes an injected
27
+ * reader and is driven directly by test/remote-file-save.test.cjs — no DOM,
28
+ * no DSH imports.
29
+ */
30
+ import type { ClientContext, SessionId } from './compat/types.ts'
31
+
32
+ /** The byte window one relayed read asks for. Matches the host default
33
+ * (`Config.maxBytes` 2 MiB) exactly: one round trip per window, and a host
34
+ * configured smaller self-corrects through the too-large branch below. */
35
+ export const DOWNLOAD_CHUNK_BYTES = 2 * 1024 * 1024
36
+
37
+ /** A hard ceiling on windows per file (4 GiB at the default size) — the
38
+ * guard against a reader that never advances (a host ignoring `range` and
39
+ * answering the same bytes forever) hanging the button in `busy`. */
40
+ const MAX_DOWNLOAD_CHUNKS = 2048
41
+
42
+ /** One byte window's answer, as the client Remote face delivers it. Only the
43
+ * fields this module reads are mirrored. */
44
+ export type RemoteByteWindow =
45
+ | { ok: true; value: { data: Uint8Array; eof: boolean } }
46
+ | { ok: false; error: { code?: string; details?: { limit?: number } } }
47
+
48
+ /** One ranged read bound to (session, path); the caller owns the closure. */
49
+ export type RemoteByteRead = (range: { offset: number; length: number }) => Promise<RemoteByteWindow>
50
+
51
+ /**
52
+ * The last path segment, as the browser download name. Slash- and
53
+ * backslash-separated paths both occur (the host may be Windows); an empty
54
+ * tail falls back to a fixed name so `download` is never blank.
55
+ * @param path - the file's absolute path on the host.
56
+ * @returns the name to save under.
57
+ */
58
+ export function downloadFileNameOf(path: string): string {
59
+ const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
60
+ const name = (cut === -1 ? path : path.slice(cut + 1)).trim()
61
+ return name === '' ? 'download' : name
62
+ }
63
+
64
+ /** The three DOM reads {@link downloadPathFromCard} performs — structural so
65
+ * tests can pass fake nodes (a real Element satisfies it). */
66
+ export interface DownloadCardNode {
67
+ closest(selector: string): DownloadCardNode | null
68
+ querySelector(selector: string): DownloadCardNode | null
69
+ getAttribute(name: string): string | null
70
+ }
71
+
72
+ /**
73
+ * The file path a produced-file card carries, read off the host's own
74
+ * rendered contract: the card root is `[data-presented-file]`, and its
75
+ * preview button's `title` is the resolved workspace path (RT
76
+ * dsh-client-ui-deliverables PresentedFileCard: `title:
77
+ * resolveWorkspacePath(cwd, file.path)`). The action button mounts inside
78
+ * that card, so `closest` picks THIS card, never a neighbouring one.
79
+ * @param button - the download action's own DOM node.
80
+ * @returns the path, or undefined when the card shape is not there (a future
81
+ * host): the caller then reports failure instead of guessing a path.
82
+ */
83
+ export function downloadPathFromCard(button: DownloadCardNode | null): string | undefined {
84
+ const card = button?.closest('[data-presented-file]')
85
+ if (card === null || card === undefined) return undefined
86
+ // Prefer the DIRECT child: the card's preview overlay is the first child
87
+ // button, while this action (and open-in-app's control) live deeper in the
88
+ // actions cell — and this action carries a `title` of its own, so a broad
89
+ // `querySelector('button[title]')` could match ITSELF on a host whose card
90
+ // markup differs. `:scope` narrows the match to the card's own children.
91
+ let preview: DownloadCardNode | null = null
92
+ try {
93
+ preview = card.querySelector(':scope > button[title]')
94
+ } catch {
95
+ preview = null
96
+ }
97
+ preview ??= card.querySelector('button[title]')
98
+ const title = preview?.getAttribute('title')?.trim()
99
+ return title === undefined || title === '' ? undefined : title
100
+ }
101
+
102
+ /**
103
+ * Read one file window by window and return its complete bytes.
104
+ *
105
+ * The window starts at `chunkBytes` and adapts DOWN when the host refuses a
106
+ * window as too large (`workspace-file/too-large` + `details.limit`), so a
107
+ * host configured below the default still completes. The loop ends at the
108
+ * host's own `eof` flag, or on an empty window (never spin on a server that
109
+ * reports neither).
110
+ * @param read - one ranged read, already bound to the session and path.
111
+ * @param chunkBytes - the first window size; tests inject a small one.
112
+ * @returns the complete file bytes, in one buffer.
113
+ * @throws when a read is refused (the code rides the message) or the reader
114
+ * keeps advancing past {@link MAX_DOWNLOAD_CHUNKS} windows.
115
+ */
116
+ export async function collectRemoteFile(
117
+ read: RemoteByteRead,
118
+ chunkBytes = DOWNLOAD_CHUNK_BYTES,
119
+ ): Promise<Uint8Array> {
120
+ const chunks: Uint8Array[] = []
121
+ let offset = 0
122
+ let size = Math.max(1, Math.floor(chunkBytes))
123
+ for (;;) {
124
+ if (chunks.length >= MAX_DOWNLOAD_CHUNKS) throw new Error('workspace-file/too-many-chunks')
125
+ const result = await read({ offset, length: size })
126
+ if (!result.ok) {
127
+ const limit = result.error.details?.limit
128
+ if (
129
+ result.error.code === 'workspace-file/too-large' &&
130
+ typeof limit === 'number' &&
131
+ Number.isSafeInteger(limit) &&
132
+ limit > 0 &&
133
+ limit < size
134
+ ) {
135
+ // The host's own window cap is below ours: take its number and retry
136
+ // the SAME offset — nothing was read.
137
+ size = limit
138
+ continue
139
+ }
140
+ throw new Error(result.error.code === undefined ? 'workspace-file/read-failed' : result.error.code)
141
+ }
142
+ const data = result.value.data
143
+ chunks.push(data)
144
+ offset += data.byteLength
145
+ if (result.value.eof || data.byteLength === 0) break
146
+ }
147
+ let total = 0
148
+ for (const chunk of chunks) total += chunk.byteLength
149
+ const out = new Uint8Array(total)
150
+ let at = 0
151
+ for (const chunk of chunks) {
152
+ out.set(chunk, at)
153
+ at += chunk.byteLength
154
+ }
155
+ return out
156
+ }
157
+
158
+ /** Save one file from a remote session to this machine. */
159
+ export type SaveRemoteFile = (sessionId: SessionId, path: string) => Promise<void>
160
+
161
+ /** The slice of the client `remote` service this module needs (the real one
162
+ * carries the whole workspaceFiles namespace; only readBytes is used). */
163
+ interface RemoteFilesFace {
164
+ readBytes(
165
+ sessionId: SessionId,
166
+ path: string,
167
+ options: { range?: { offset: number; length: number } },
168
+ signal?: AbortSignal,
169
+ ): Promise<RemoteByteWindow>
170
+ }
171
+
172
+ /** Hand a finished byte array to the browser's download machinery. */
173
+ function saveBytes(data: Uint8Array, fileName: string): void {
174
+ // `new Uint8Array(length)` always owns a plain ArrayBuffer; the cast only
175
+ // satisfies the BlobPart union in DOM typings that predate generic views.
176
+ const blob = new Blob([data as unknown as BlobPart], { type: 'application/octet-stream' })
177
+ const url = URL.createObjectURL(blob)
178
+ const anchor = document.createElement('a')
179
+ anchor.href = url
180
+ anchor.download = downloadFileNameOf(fileName)
181
+ anchor.rel = 'noopener'
182
+ anchor.style.display = 'none'
183
+ document.body.append(anchor)
184
+ anchor.click()
185
+ anchor.remove()
186
+ setTimeout(() => {
187
+ URL.revokeObjectURL(url)
188
+ }, 60_000)
189
+ }
190
+
191
+ /**
192
+ * Bind the save capability the download buttons share to the client context.
193
+ *
194
+ * The `remote.workspaceFiles` service is looked up at CLICK time, not at
195
+ * registration time: a host without the service (an older DSH) must not
196
+ * break the plugin's apply, and the buttons only ever mount for a virtual
197
+ * session — where the service exists by construction.
198
+ * @param ctx - client root context.
199
+ * @returns the save function the injected `saveFile` prop carries.
200
+ */
201
+ export function createSaveRemoteFile(ctx: ClientContext): SaveRemoteFile {
202
+ return async (sessionId, path) => {
203
+ // The namespace is BOTH a service of its own (`remote.workspaceFiles`) and
204
+ // reachable through the `remote` service's namespace proxy; ask for the
205
+ // exact service first and fall back to the proxy, so a composition that
206
+ // only provides one of the two still works.
207
+ const direct = ctx.get('remote.workspaceFiles') as RemoteFilesFace | undefined
208
+ const remote = ctx.get('remote') as { workspaceFiles?: RemoteFilesFace } | undefined
209
+ const files = direct ?? remote?.workspaceFiles
210
+ if (files === undefined) throw new Error('remote-unavailable')
211
+ let data: Uint8Array
212
+ try {
213
+ data = await collectRemoteFile((range) => files.readBytes(sessionId, path, { range }, undefined))
214
+ } catch (error) {
215
+ throw new Error(`read: ${describeError(error)}`, { cause: error })
216
+ }
217
+ try {
218
+ saveBytes(data, path)
219
+ } catch (error) {
220
+ throw new Error(`save: ${describeError(error)}`, { cause: error })
221
+ }
222
+ }
223
+ }
224
+
225
+ /** A short, user-visible reason: an error's message (the relay's failures
226
+ * carry their code as the message), else the thrown value's text. Kept short
227
+ * because it is rendered INSIDE the card's button. */
228
+ function describeError(error: unknown): string {
229
+ const text = error instanceof Error ? error.message : String(error)
230
+ const trimmed = text.trim()
231
+ return trimmed.length > 40 ? `${trimmed.slice(0, 40)}…` : trimmed === '' ? 'unknown' : trimmed
232
+ }