dsh-zen-remote 2.2.4 → 2.2.6
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 -3
- package/README.md +12 -4
- package/README.zh-CN.md +12 -4
- package/docs/interface.md +18 -1
- package/docs/plugin-bridge.md +1 -1
- package/docs/remote-access.en.md +10 -3
- package/docs/remote-access.md +10 -3
- package/lib/adapters/dsh-better-sidebar/index.js +28 -7
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/adapters/dsh-better-sidebar/methods.js +2 -0
- package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -1
- package/lib/client-routes.js +3 -0
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +1164 -193
- package/lib/fetch-route-intercept.js +1 -1
- package/lib/index.js +73 -16
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +13 -0
- package/lib/intercept.js.map +1 -1
- package/lib/invoke-binary.js +8 -2
- package/lib/invoke-binary.js.map +1 -1
- package/lib/lan-gate-server.cjs +109 -2
- package/lib/owner-peer.js +23 -0
- package/lib/owner-peer.js.map +1 -1
- package/lib/plugin-adapter-core.js +2 -2
- package/lib/relay-access.js +24 -2
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-client.js +2 -2
- package/lib/relay-server.js +103 -100
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -1
- package/lib/types/client/MobileAttachButton.d.ts +5 -2
- package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
- package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/RemoteFileDownload.d.ts +57 -0
- package/lib/types/client/RemoteFileDownload.d.ts.map +1 -0
- package/lib/types/client/attach-upload.d.ts +32 -0
- package/lib/types/client/attach-upload.d.ts.map +1 -1
- package/lib/types/client/effects/remote-drop.d.ts +27 -0
- package/lib/types/client/effects/remote-drop.d.ts.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +11 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote-file-save.d.ts +112 -0
- package/lib/types/client/remote-file-save.d.ts.map +1 -0
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/compat.css.d.ts +1 -1
- package/lib/types/client/styles/compat.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/home.css.d.ts +1 -1
- package/lib/types/client/styles/home.css.d.ts.map +1 -1
- package/lib/types/client/styles/layout.css.d.ts +1 -1
- package/lib/types/client/styles/layout.css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client-data/remote-drop.d.ts +99 -0
- package/lib/types/client-data/remote-drop.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +7 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +6 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/invoke-binary.d.ts +4 -1
- package/lib/types/invoke-binary.d.ts.map +1 -1
- package/lib/types/owner-peer.d.ts +9 -0
- package/lib/types/owner-peer.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts +1 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts +10 -7
- package/lib/types/relay-server.d.ts.map +1 -1
- package/package.json +37 -37
- package/pwa/inject.js +20 -9
- package/pwa/sw.js +97 -21
- package/scripts/check-attach-upload.mjs +37 -0
- package/scripts/check-remote-drop.mjs +114 -0
- package/scripts/check-settings-form.mjs +3 -2
- package/scripts/check-upload-endpoint.mjs +14 -3
- package/src/adapters/dsh-better-sidebar/index.ts +27 -7
- package/src/adapters/dsh-better-sidebar/methods.ts +2 -0
- package/src/client/MobileAttachButton.tsx +14 -9
- package/src/client/MobileAttachChips.tsx +4 -0
- package/src/client/MobileHome.tsx +79 -12
- package/src/client/RemoteFileDownload.tsx +173 -0
- package/src/client/attach-upload.ts +47 -0
- package/src/client/effects/remote-drop.ts +269 -0
- package/src/client/index.tsx +47 -0
- package/src/client/locales.ts +32 -0
- package/src/client/remote-file-save.ts +232 -0
- package/src/client/settings/SettingsSection.tsx +61 -1
- package/src/client/styles/compat.css.ts +43 -12
- package/src/client/styles/header.css.ts +63 -15
- package/src/client/styles/home.css.ts +39 -1
- package/src/client/styles/layout.css.ts +24 -7
- package/src/client/styles/remote-session.css.ts +38 -0
- package/src/client-data/remote-drop.ts +127 -0
- package/src/client-data/settings-form.ts +10 -0
- package/src/client-routes.ts +8 -0
- package/src/fetch-route-intercept.ts +1 -1
- package/src/index.ts +71 -18
- package/src/intercept.ts +13 -0
- package/src/invoke-binary.ts +8 -2
- package/src/owner-peer.ts +32 -0
- package/src/plugin-adapter-core.ts +2 -2
- package/src/relay-access.ts +24 -2
- package/src/relay-client.ts +2 -2
- 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
|
+
}
|
package/src/client/index.tsx
CHANGED
|
@@ -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
|
package/src/client/locales.ts
CHANGED
|
@@ -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
|
+
}
|