/** * Remote-session drag-and-drop decision core (RD-1): the DOM-free, readable * half of the document-level drop guard (src/client/effects/remote-drop.ts). * * Why this exists: the desktop app's attachment pipeline has a same-machine * zero-copy fast path — the Electron preload exposes `__DSH_HOST_PATHS__`, * and the host's `addFiles` turns a File that still carries a real disk path * into an `@/absolute/path` mention chip WITHOUT uploading any bytes. In a * remote session (`zr~~`) that path names a file on the * SUB-CLIENT's machine; the server-side session can never read it, so the * mention lands broken. The fix re-shapes the drop before the host sees it: * strip the internal path by re-wrapping each File (`new File([f], ...)`) and * redispatch a synthetic, untrusted DragEvent carrying them in a fresh * DataTransfer. The host's own drop handler then treats it as an ordinary * browser drag — the byte-upload branch — which the T51 fetch-route * interception already relays to the server for staging. * * This module only DECIDES; it never touches the DOM: * - `isRemoteSessionId` — the current-session gate, judged by the same * `zr~` skeleton (virtual-id.ts) every other remote gate in this plugin * keys on, so the two ends cannot drift; * - `decideRemoteDrop` — the three trigger gates plus the directory rule, * as a pure function over injectable data so the whole truth table is * assertable in Node (scripts/check-remote-drop.mjs); * - `rewrapFiles` — the File re-wrap itself (Node >= 20 has File). * * Misjudgement direction is safe by construction: every gate failing open * lands on `passthrough` = today's behavior. The only wrong action a stale * current-session signal can produce is re-shaping a LOCAL session's drop * into an upload — bytes travel that did not have to, but nothing is lost. * The reverse (a remote drop reaching the host untouched) is what loses the * attachment, which is why all three gates must affirmatively pass. */ import { isVirtual } from '../virtual-id.ts' /** True when the id is one of this plugin's virtual (relay) session ids — * the `zr~~` form. `undefined` (no readable current-session * signal) is deliberately NOT remote: the conservative gate reads a missing * signal as a local session and never intervenes. */ export function isRemoteSessionId(id: string | undefined): boolean { return typeof id === 'string' && isVirtual(id) } /** One drag item, reduced to what the decision needs. `kind` mirrors the * DataTransferItem kind — only `file` items can carry attachment bytes. * `isDirectory` comes from `webkitGetAsEntry().isDirectory`; a file item * whose entry cannot be read counts as NOT a directory (the host applies * the same default). */ export interface RemoteDropItemDescriptor { kind: 'file' | 'other' isDirectory: boolean } /** Everything the decision may look at, injectable so no DOM is needed. */ export interface RemoteDropInput { /** `__DSH_HOST_PATHS__` exists — the Electron preload's zero-copy bridge. * False on phone / plain-browser hosts, which already upload bytes. */ hasHostPaths: boolean /** The currently open session id, or `undefined` when no signal. */ currentSessionId: string | undefined /** `event.isTrusted` — our own redispatched events are untrusted; this * gate is the re-entry guard. */ trusted: boolean /** Descriptor per file-kind drag item. `undefined` means the entry API * (`webkitGetAsEntry`) is unavailable, so directories cannot be detected — * the host's own semantics treat that as "no directories". */ items: readonly RemoteDropItemDescriptor[] | undefined } export type RemoteDropAction = 'passthrough' | 'redispatch' | 'block-directories' /** Stable machine-readable why, one per action branch. Ordered by the gate * sequence below. */ export type RemoteDropReason = | 'untrusted-event' | 'no-host-paths' | 'not-remote-session' | 'no-entry-api' | 'contains-directory' | 'no-files' | 'remote-files' export interface RemoteDropDecision { action: RemoteDropAction reason: RemoteDropReason } /** * The three trigger gates, then the directory rule. Every gate must * affirmatively pass before anything is intervened on: * 1. `trusted` — synthetic (our own) events pass straight through, which is * what makes the redispatch loop-free; * 2. `hasHostPaths` — without the Electron bridge the host is already on * the byte-upload branch and there is nothing to fix; * 3. remote session — a missing/failed current-session read counts as local * (conservative: passthrough keeps today's behavior); * 4. no entry API — redispatch (no directory knowledge, same default as the * host); * 5. any directory — `block-directories`, which takes precedence over * redispatch: a folder cannot be fixed by single-file upload, so the * whole drop is stopped with a toast rather than silently reduced; * 6. no file items at all — passthrough (the host's own `Files`-in-types * gate would ignore the drop; there is nothing to re-shape); * 7. otherwise — `redispatch`. */ export function decideRemoteDrop(input: RemoteDropInput): RemoteDropDecision { if (!input.trusted) return { action: 'passthrough', reason: 'untrusted-event' } if (!input.hasHostPaths) return { action: 'passthrough', reason: 'no-host-paths' } if (!isRemoteSessionId(input.currentSessionId)) return { action: 'passthrough', reason: 'not-remote-session' } if (input.items === undefined) return { action: 'redispatch', reason: 'no-entry-api' } if (input.items.some((item) => item.isDirectory)) return { action: 'block-directories', reason: 'contains-directory' } if (!input.items.some((item) => item.kind === 'file')) return { action: 'passthrough', reason: 'no-files' } return { action: 'redispatch', reason: 'remote-files' } } /** * Re-wrap files so they stop carrying their disk paths. A File the browser * constructed from an OS drop keeps a hidden reference the Electron * `getPathForFile` can read; `new File([f], ...)` builds a JS-constructed * File with no such backing — `getPathForFile` returns "" for it, which * sends the host's `addFiles` down the byte-upload branch. name / type / * lastModified are preserved so the staged upload keeps its filename. */ export function rewrapFiles(files: Iterable): File[] { return Array.from(files, (file) => new File([file], file.name, { type: file.type, lastModified: file.lastModified })) }