/** * Dragging files OUT of filex — onto the desktop, into another app. * * There are exactly two ways a page can hand real files to the operating * system, and filex uses both because neither one alone covers the ask: * * 1. **`DownloadURL`** (any Chromium browser, so the web explorer AND the * desktop app get it for free). One `dataTransfer.setData('DownloadURL', * 'mime:name:url')` and the browser downloads that URL into wherever the * drop landed. Costs nothing until the drop happens — but Chromium carries * exactly ONE such entry, so it is a single-file gesture, and the URL is * fetched by the browser's own download stack, which sends cookies but no * Authorization header (see `canDownloadUrlDrag`). * * 2. **A native OS drag** (`webContents.startDrag`, desktop app only). Takes a * list of real paths, so folders and multi-selections drop as separate real * files — the WinRAR gesture. The catch is in the word *real*: the shell * copies the bytes at DROP time, from a path, so the file has to be on this * computer BEFORE the drag starts. There is no lazy/virtual-file API to * borrow (that is a Win32 `IDataObject` affair Chromium does not expose). * * ⚠ The native drag REPLACES the HTML5 drag — `startDrag` requires * `preventDefault()` on `dragstart`, and after that the app's own drop targets * see an OS file drag instead of `application/x-brf-files`. Dropping a row on a * folder inside filex would then UPLOAD the temp copy back to the server: a * round trip of the same bytes, a copy instead of a move, and the original left * behind. That is what `beginNativeDrag` is for — the payload is remembered on * this side, and every drop target asks `internalDragItems(ev)` rather than * reading the dataTransfer directly. An OS drag we started is still an internal * drag when it lands inside our own window. * * Because the bytes must exist first, the shell is asked to prepare them on * `mousedown` — before the gesture becomes a drag — and the native path is only * taken when it answered "ready". Anything else keeps the ordinary HTML5 drag, * so an internal move never waits on a download it does not need. */ export interface DragItem { path: string; basename: string; type: 'file' | 'dir'; } export const FE_DND_MIME = 'application/x-brf-files'; export const FE_DND_SRC_MIME = 'application/x-brf-files-src'; /** An OS drag this window started, and where it came from. */ interface NativeDrag { items: DragItem[]; origin: string; startedAt: number; } let native: NativeDrag | null = null; /** * A native drag has no `dragend` on our side (the HTML5 one never started), so * the record is dropped by whoever notices the gesture is over — a drop we * handled, a pointerup, or this ceiling. Five minutes is far longer than any * drag and short enough that a stale record cannot outlive the user's memory * of it. */ const NATIVE_DRAG_TTL_MS = 5 * 60_000; export function beginNativeDrag(items: DragItem[], origin: string): void { native = { items: items.slice(), origin, startedAt: Date.now() }; } export function endNativeDrag(): void { native = null; } export function activeNativeDrag(): NativeDrag | null { if (!native) return null; if (Date.now() - native.startedAt > NATIVE_DRAG_TTL_MS) { native = null; return null; } return native; } /** * True when this drag is filex's own — either the HTML5 payload is on the * dataTransfer, or we are mid-native-drag. Safe to call from `dragover`, where * `getData` is blocked and only `types` may be read. */ export function hasInternalDrag(ev: DragEvent): boolean { if (ev.dataTransfer?.types.includes(FE_DND_MIME)) return true; return activeNativeDrag() !== null; } /** The dragged rows, from whichever of the two drag mechanisms is in play. */ export function internalDragItems(ev: DragEvent): DragItem[] | null { const raw = ev.dataTransfer?.getData(FE_DND_MIME); if (raw) { try { const parsed = JSON.parse(raw) as DragItem[]; return Array.isArray(parsed) && parsed.length > 0 ? parsed : null; } catch { return null; } } const n = activeNativeDrag(); return n && n.items.length > 0 ? n.items : null; } /** The folder a drag started from — the origin stamp used by move ops. */ export function internalDragOrigin(ev: DragEvent): string | undefined { const v = ev.dataTransfer?.getData(FE_DND_SRC_MIME); if (v) return v; return activeNativeDrag()?.origin || undefined; } /** * Can this install hand a plain URL to the browser's download stack? * * Only when the credential travels on its own: a cookie session (the web * explorer) or no auth at all. With a bearer token the page holds the * credential and the download would arrive unauthenticated — a drop that * silently produces a 401 page named like the file is worse than a drop that * does nothing, so the desktop app (bearer) uses the native path instead. */ export function canDownloadUrlDrag(auth: unknown): boolean { const kind = authKindOf(auth); return kind === undefined || kind === 'none' || kind === 'csrf'; } /** `AuthConfig` carries the strategy as `kind`, or as `type` in the 0.1.0 * shape embedders still pass. Both mean the same thing here. */ export function authKindOf(auth: unknown): string | undefined { if (!auth || typeof auth !== 'object') return undefined; const a = auth as { kind?: unknown; type?: unknown }; const raw = typeof a.kind === 'string' ? a.kind : typeof a.type === 'string' ? a.type : undefined; return raw; } /** * The `DownloadURL` value Chromium wants: `::`. * * ⚠ Absolute. A relative URL is accepted by `setData` and then fails to * resolve in the browser process, which is a drop that quietly does nothing. */ export function downloadUrlPayload(item: DragItem, url: string, mime?: string): string | null { if (item.type !== 'file') return null; let abs = url; try { abs = new URL(url, typeof location !== 'undefined' ? location.href : undefined).toString(); } catch { return null; } if (!/^https?:/i.test(abs)) return null; const name = item.basename.replace(/[:\r\n]/g, '_'); return `${mime && mime.trim() ? mime : 'application/octet-stream'}:${name}:${abs}`; } /** Stable key for "is the shell's prepared set still the one being dragged?" */ export function dragKey(items: Array<{ path: string }>): string { return items .map((i) => i.path) .sort() .join(''); }