From b805571dec2cdb5cb633e0e9c81becc84826b99f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:30:37 +0100 Subject: [PATCH] fix: drag attachments and messages out of the desktop app with their bytes in hand --- components/email/email-viewer.tsx | 9 ++++ .../__tests__/use-email-drag-routing.test.tsx | 5 +-- hooks/use-attachment-drag.ts | 43 +++++++++++++------ hooks/use-email-drag.ts | 26 +++++++---- lib/desktop/__tests__/os-window.test.tsx | 30 ++++++++++--- lib/desktop/file-drag.ts | 26 ++++++++--- 6 files changed, 101 insertions(+), 38 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 09c925b5e..9dbde37b4 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -685,6 +685,15 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl const source = useMemo(() => ({ name: downloadName || attachment.name || 'download', type: attachment.type || 'application/octet-stream', + getBlob: async () => { + if (attachment.blobId && client) { + return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null); + } + const bytes = attachment.tnefData ?? (attachment.decryptedAttachment ? getAttachmentContentBytes(attachment.decryptedAttachment) : null); + if (!bytes || bytes.byteLength === 0) return null; + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + return new Blob([buffer], { type: inertBlobType(attachment.type) }); + }, getBlobUrl: async () => { if (attachment.blobId && client) { try { diff --git a/hooks/__tests__/use-email-drag-routing.test.tsx b/hooks/__tests__/use-email-drag-routing.test.tsx index 242f40e5f..e3757d946 100644 --- a/hooks/__tests__/use-email-drag-routing.test.tsx +++ b/hooks/__tests__/use-email-drag-routing.test.tsx @@ -734,11 +734,10 @@ describe('message row drag hover wiring', () => { it("in the desktop app an Alt drag is the OS's own with the real bytes, and a plain one stays the page's", async () => { const email = mail({ subject: 'Hello world' }); - const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' })); - await warm(result); const nativeDrag = vi.fn(async (_files: { name: string; data: Uint8Array }[]) => true); window.bulwarkDesktop = { version: 1, platform: 'win32', os: { startDrag: nativeDrag } } as never; - vi.stubGlobal('fetch', vi.fn(async (url: string) => new Response(await readBlob(objects.get(url)!)))); + const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' })); + await warm(result); try { startDrag.mockClear(); // Plain: still a move to a folder, still DownloadURL for Explorer. diff --git a/hooks/use-attachment-drag.ts b/hooks/use-attachment-drag.ts index ce0047251..6136c4453 100644 --- a/hooks/use-attachment-drag.ts +++ b/hooks/use-attachment-drag.ts @@ -34,6 +34,8 @@ export interface AttachmentDragSource { name: string; type: string; getBlobUrl: () => Promise; + /** The bytes themselves, for the desktop app's native drag. */ + getBlob?: () => Promise; } export interface UseAttachmentDragResult { @@ -59,6 +61,8 @@ export function useAttachmentDrag( const urlRef = useRef(null); const ownedRef = useRef(false); const inFlightRef = useRef | null>(null); + const blobRef = useRef(null); + const nativeDrag = canDragNativeFiles() && !!source.getBlob; useEffect(() => { return () => { @@ -73,6 +77,19 @@ export function useAttachmentDrag( const prefetch = useCallback(() => { if (!enabled) return; + if (nativeDrag) { + if (blobRef.current || inFlightRef.current) return; + inFlightRef.current = source.getBlob!() + .then((blob) => { + blobRef.current = blob; + return null; + }) + .catch(() => null) + .finally(() => { + inFlightRef.current = null; + }); + return; + } if (urlRef.current || inFlightRef.current) return; inFlightRef.current = source .getBlobUrl() @@ -90,7 +107,7 @@ export function useAttachmentDrag( .finally(() => { inFlightRef.current = null; }); - }, [enabled, source]); + }, [enabled, source, nativeDrag]); const handleDragStart = useCallback( (e: DragEvent) => { @@ -98,6 +115,15 @@ export function useAttachmentDrag( const name = source.name || "download"; const type = source.type || "application/octet-stream"; + // In the desktop app the OS's own drag, with the real file on disk first. The bytes + // aren't here yet: fetch them for the next try, as below. + if (nativeDrag) { + e.preventDefault(); + if (blobRef.current) void startNativeFileDrag([{ name, blob: blobRef.current }]); + else prefetch(); + return; + } + if (!url) { // Blob isn't materialized yet. Kick off the fetch so the next attempt // works, but cancel this drag so the user doesn't get a silent failure @@ -107,13 +133,6 @@ export function useAttachmentDrag( return; } - // In the desktop app the OS's own drag, with the real file on disk first. - if (canDragNativeFiles()) { - e.preventDefault(); - void startNativeFileDrag([{ name, url }]); - return; - } - // `DownloadURL` format: ::. The filename must be // raw - URL-encoding it lands literally on disk (`%20` instead of a // space). Callers are expected to sanitise reserved chars (`:` etc.) @@ -121,7 +140,7 @@ export function useAttachmentDrag( e.dataTransfer.setData("DownloadURL", `${type}:${name}:${url}`); e.dataTransfer.effectAllowed = "copyMove"; }, - [source.name, source.type, prefetch], + [source.name, source.type, prefetch, nativeDrag], ); const handleDragEnd = useCallback(() => { @@ -136,8 +155,8 @@ export function useAttachmentDrag( }, []); const copyAsFile = useCallback(async () => { - const url = urlRef.current ?? await source.getBlobUrl(); - return url ? copyFilesToClipboard([{ name: source.name || "download", url }]) : false; + const blob = blobRef.current ?? await source.getBlob?.(); + return blob ? copyFilesToClipboard([{ name: source.name || "download", blob }]) : false; }, [source]); if (!enabled) return NOOP_HANDLERS; @@ -147,6 +166,6 @@ export function useAttachmentDrag( onPointerEnter: prefetch, onDragStart: handleDragStart, onDragEnd: handleDragEnd, - ...(canDragNativeFiles() ? { onCopyAsFile: copyAsFile } : {}), + ...(nativeDrag ? { onCopyAsFile: copyAsFile } : {}), }; } diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts index b60a4e910..6debff52c 100644 --- a/hooks/use-email-drag.ts +++ b/hooks/use-email-drag.ts @@ -9,7 +9,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useUIStore } from "@/stores/ui-store"; import { isDragOutSupported } from "@/hooks/use-attachment-drag"; -import { canDragNativeFiles, startNativeFileDrag } from "@/lib/desktop/file-drag"; +import { canDragNativeFiles, forgetDragBlob, keepDragBlob, keptDragBlob, startNativeFileDrag } from "@/lib/desktop/file-drag"; import { canExport } from "@/lib/device-policy"; import { appUrl, buildMailPath } from "@/lib/deep-links"; import { @@ -104,7 +104,10 @@ function selectionKey(emails: RoutedEmail[], bundleOptions: EmailFilenameOptions function revokeLater(url: string): void { // Chromium may read the URL asynchronously after the drop completes. - setTimeout(() => URL.revokeObjectURL(url), 60_000); + setTimeout(() => { + URL.revokeObjectURL(url); + forgetDragBlob(url); + }, 60_000); } async function buildEmailZip(emails: RoutedEmail[]): Promise { @@ -127,7 +130,7 @@ async function buildEmailZip(emails: RoutedEmail[]): Promise { }), ); const zipBlob = await zip.generateAsync({ type: "blob", mimeType: "application/zip" }); - return URL.createObjectURL(zipBlob); + return keepDragBlob(URL.createObjectURL(zipBlob), zipBlob); } function prefetchEmailBundle( @@ -225,8 +228,11 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD if (previous?.url) revokeLater(previous.url); const entry: BundleEntry = { key: route.key, name: route.name, url: null, promise: null }; singleRef.current = entry; - entry.promise = route.client - .fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId) + // The desktop app's native drag needs the bytes as well as the address. + const fetched = canDragNativeFiles() + ? route.client.fetchBlob(target.blobId, route.name, "message/rfc822", route.accountId).then((blob) => keepDragBlob(URL.createObjectURL(blob), blob)) + : route.client.fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId); + entry.promise = fetched .then((url) => { if (url) { if (singleRef.current === entry && routeEmail(target, filenameOptions).key === entry.key) entry.url = url; @@ -298,9 +304,10 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const name = emailExportFilename(emailsToDrag[0], filenameOptions); // Desktop app, Alt held: the OS's own drag with the real file. A plain drag has // to stay the page's, because it also moves the message to a folder. - if (e.altKey && canDragNativeFiles()) { + const blob = e.altKey ? keptDragBlob(url) : undefined; + if (blob) { e.preventDefault(); - void startNativeFileDrag([{ name, url }]); + void startNativeFileDrag([{ name, blob }]); return; } // `DownloadURL` format: ::. Chromium expects @@ -321,9 +328,10 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const routed = emailsToDrag.map((em) => routeEmail(em, filenameOptions)); const ready = getReadyBundle(routed, bundleOptions); if (ready) { - if (e.altKey && canDragNativeFiles()) { + const blob = e.altKey ? keptDragBlob(ready.url) : undefined; + if (blob) { e.preventDefault(); - void startNativeFileDrag([{ name: ready.name, url: ready.url }]); + void startNativeFileDrag([{ name: ready.name, blob }]); return; } e.dataTransfer.setData( diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx index 301ee9abc..f7f3a5012 100644 --- a/lib/desktop/__tests__/os-window.test.tsx +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -200,23 +200,23 @@ describe('files on the window', () => { describe('dragging an attachment out', () => { const blobUrl = 'blob:https://app/1'; - const source = { name: 'report.pdf', type: 'application/pdf', getBlobUrl: async () => blobUrl }; + const source = { + name: 'report.pdf', + type: 'application/pdf', + getBlobUrl: async () => blobUrl, + getBlob: async () => new Blob([new Uint8Array([1, 2, 3])]), + }; async function ready() { const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag'); const hook = renderHook(() => useAttachmentDrag(source, true)); act(() => hook.result.current.onPointerEnter()); await act(async () => { - await Promise.resolve(); - await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); }); return hook; } - beforeEach(() => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1, 2, 3])))); - }); - it('in the app: the OS\'s own drag, with the bytes for a real file', async () => { const startDrag = vi.fn(async () => true); setBridge({ startDrag, copyFiles: vi.fn() as never }); @@ -231,6 +231,22 @@ describe('dragging an attachment out', () => { expect([...files[0].data]).toEqual([1, 2, 3]); }); + it('in the app, a drag before the bytes are here starts nothing and fetches them for the next', async () => { + const startDrag = vi.fn(async () => true); + setBridge({ startDrag, copyFiles: vi.fn() as never }); + const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag'); + const hook = renderHook(() => useAttachmentDrag(source, true)); + const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } }; + hook.result.current.onDragStart(event as never); + expect(event.preventDefault).toHaveBeenCalled(); + expect(startDrag).not.toHaveBeenCalled(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + hook.result.current.onDragStart(event as never); + await waitFor(() => expect(startDrag).toHaveBeenCalled()); + }); + it('in a browser: still DownloadURL, and no copy button', async () => { const hook = await ready(); const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } }; diff --git a/lib/desktop/file-drag.ts b/lib/desktop/file-drag.ts index c3f6476a7..07084646c 100644 --- a/lib/desktop/file-drag.ts +++ b/lib/desktop/file-drag.ts @@ -6,11 +6,26 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; import { setOwnDragActive } from '@/lib/desktop/window-files'; +/** The page's CSP keeps fetch off blob: URLs, so a file travels as its Blob. */ export interface DragFile { name: string; - /** A blob: URL the page already holds. */ - url?: string; - data?: Uint8Array; + blob: Blob; +} + +const keptBlobs = new Map(); + +/** In the app, remembers what's behind an object URL made for dragging. */ +export function keepDragBlob(url: string, blob: Blob): string { + if (canDragNativeFiles()) keptBlobs.set(url, blob); + return url; +} + +export function keptDragBlob(url: string): Blob | undefined { + return keptBlobs.get(url); +} + +export function forgetDragBlob(url: string): void { + keptBlobs.delete(url); } export function canDragNativeFiles(): boolean { @@ -18,10 +33,7 @@ export function canDragNativeFiles(): boolean { } async function toBytes(file: DragFile): Promise<{ name: string; data: Uint8Array }> { - if (file.data) return { name: file.name, data: file.data }; - if (!file.url) throw new Error('A file needs its bytes or a URL'); - const response = await fetch(file.url); - return { name: file.name, data: new Uint8Array(await response.arrayBuffer()) }; + return { name: file.name, data: new Uint8Array(await file.blob.arrayBuffer()) }; } /**