From c0b65ea3a74df0d77b36f8f21400dc1bc645271d Mon Sep 17 00:00:00 2001 From: Muammer Date: Thu, 27 Aug 2026 14:52:40 +0300 Subject: [PATCH 1/2] fix(desktop): attach OS file drops instead of inserting their paths Linux WebKitGTK (and some other webviews) deliver file-manager drops as file:// URIs or absolute paths instead of File objects. ProseMirror then inserts the path as composer text. Claim those drops in editorProps.handleDrop, recover paths from text/uri-list, and upload them through the same TOCTOU-safe pipeline as the paperclip picker. Signed-off-by: Muammer --- desktop/src-tauri/src/commands/media.rs | 71 +++++++++ desktop/src-tauri/src/lib.rs | 1 + .../src/app/useAppShellLifecycleEffects.ts | 3 +- .../src/features/forum/ui/ForumComposer.tsx | 19 +++ .../messages/lib/droppedFiles.test.mjs | 143 ++++++++++++++++++ .../src/features/messages/lib/droppedFiles.ts | 123 +++++++++++++++ .../features/messages/lib/useMediaUpload.ts | 69 +++++++-- .../features/messages/ui/MessageComposer.tsx | 3 + .../messages/ui/useComposerPasteHandler.ts | 21 +++ desktop/src/shared/api/tauriMedia.ts | 17 +++ 10 files changed, 454 insertions(+), 16 deletions(-) create mode 100644 desktop/src/features/messages/lib/droppedFiles.test.mjs create mode 100644 desktop/src/features/messages/lib/droppedFiles.ts diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 8da845c07d4..948426997c6 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -703,6 +703,54 @@ pub async fn pick_and_upload_image( Ok(Some(descriptor)) } +const MAX_DROPPED_FILES: usize = 32; + +fn validate_dropped_path(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("empty dropped path".to_string()); + } + let path = std::path::PathBuf::from(trimmed); + if !path.is_absolute() { + return Err("dropped path must be absolute".to_string()); + } + let meta = std::fs::metadata(&path).map_err(|e| format!("cannot read dropped file: {e}"))?; + if !meta.is_file() { + return Err("dropped path must be a regular file".to_string()); + } + Ok(path) +} + +/// Upload files dropped from the OS file manager onto the composer. +/// +/// Linux WebKitGTK often delivers `file://` URIs / absolute paths instead of +/// `File` objects. The renderer recovers those paths and this command opens +/// them through the same TOCTOU-safe pipeline as [`pick_and_upload_media`]. +/// User-initiated: the user dropped the files onto Buzz. +#[tauri::command] +pub async fn upload_dropped_media( + paths: Vec, + progress_id: Option, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result, String> { + if paths.is_empty() { + return Ok(Vec::new()); + } + if paths.len() > MAX_DROPPED_FILES { + return Err(format!("too many dropped files (max {MAX_DROPPED_FILES})")); + } + + let mut descriptors = Vec::with_capacity(paths.len()); + for raw in paths { + let path = validate_dropped_path(&raw)?; + let progress = progress_id.clone().map(|id| (app.clone(), id)); + let descriptor = process_picked_path(path, &state, false, progress).await?; + descriptors.push(descriptor); + } + Ok(descriptors) +} + pub(super) async fn upload_media_bytes_inner( data: Vec, filename: Option, @@ -982,6 +1030,29 @@ mod tests { )); } + #[test] + fn test_validate_dropped_path_rejects_relative_and_empty() { + assert!(validate_dropped_path("").is_err()); + assert!(validate_dropped_path("photo.png").is_err()); + assert!(validate_dropped_path("./photo.png").is_err()); + } + + #[test] + fn test_validate_dropped_path_rejects_missing_and_directory() { + assert!(validate_dropped_path("/no/such/buzz-drop-test-file").is_err()); + assert!(validate_dropped_path(std::env::temp_dir().to_str().unwrap()).is_err()); + } + + #[test] + fn test_validate_dropped_path_accepts_regular_file() { + let path = + std::env::temp_dir().join(format!("buzz-drop-validate-{}", uuid::Uuid::new_v4())); + std::fs::write(&path, b"ok").unwrap(); + let result = validate_dropped_path(path.to_str().unwrap()); + let _ = std::fs::remove_file(&path); + assert!(result.is_ok()); + } + #[test] fn test_sanitize_filename() { assert_eq!(sanitize_filename("report.pdf"), "report.pdf"); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 613040b8095..798141c105c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -663,6 +663,7 @@ pub fn run() { upload_media, pick_and_upload_media, pick_and_upload_image, + upload_dropped_media, upload_media_bytes, upload_media_bytes_raw, cancel_media_upload, diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index 71db0523695..ea8a17ca290 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -28,7 +28,8 @@ export function useAppShellLifecycleEffects({ // Composer's onDrop fires first (React synthetic before window bubble). React.useEffect(() => { function preventNavigation(e: DragEvent) { - if (e.dataTransfer?.types.includes("Files")) { + const types = Array.from(e.dataTransfer?.types ?? []); + if (types.includes("Files") || types.includes("text/uri-list")) { e.preventDefault(); } } diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 961716aaf48..293aad35016 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -5,6 +5,10 @@ import { ChevronDown } from "lucide-react"; import { buildOutgoingMessage } from "@/features/messages/lib/imetaMediaMarkdown"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; +import { + extractDroppedFilePayload, + isOsFileDrag, +} from "@/features/messages/lib/droppedFiles"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { useMentions } from "@/features/messages/lib/useMentions"; import { @@ -352,6 +356,8 @@ export function ForumComposer({ // ── Media paste ───────────────────────────────────────────────────── const uploadFileRef = React.useRef(media.uploadFile); uploadFileRef.current = media.uploadFile; + const handleDropRef = React.useRef(media.handleDrop); + handleDropRef.current = media.handleDrop; React.useEffect(() => { if (!richText.editor) return; @@ -359,6 +365,19 @@ export function ForumComposer({ richText.editor.setOptions({ editorProps: { ...richText.editor.options.editorProps, + handleDrop: (_view, event) => { + const dragEvent = event as DragEvent; + const payload = extractDroppedFilePayload(dragEvent.dataTransfer); + const isFileDrop = + isOsFileDrag(dragEvent.dataTransfer) || + payload.files.length > 0 || + payload.paths.length > 0; + if (!isFileDrop) return false; + dragEvent.preventDefault(); + dragEvent.stopPropagation(); + void handleDropRef.current(dragEvent); + return true; + }, handlePaste: (_view, event) => { const items = Array.from(event.clipboardData?.items ?? []); // Any actual file pastes as an attachment; text/string items fall diff --git a/desktop/src/features/messages/lib/droppedFiles.test.mjs b/desktop/src/features/messages/lib/droppedFiles.test.mjs new file mode 100644 index 00000000000..8a7d3d37e14 --- /dev/null +++ b/desktop/src/features/messages/lib/droppedFiles.test.mjs @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + basenameFromPath, + extractDroppedFilePayload, + extractPathsFromText, + fileUriOrAbsolutePath, + isOsFileDrag, + looksLikeFileName, +} from "./droppedFiles.ts"; + +test("fileUriOrAbsolutePath accepts unix absolute paths", () => { + assert.equal( + fileUriOrAbsolutePath("/home/me/Pictures/cat.png"), + "/home/me/Pictures/cat.png", + ); +}); + +test("fileUriOrAbsolutePath accepts file:// URIs", () => { + assert.equal( + fileUriOrAbsolutePath("file:///home/me/Pictures/cat.png"), + "/home/me/Pictures/cat.png", + ); +}); + +test("fileUriOrAbsolutePath decodes percent-encoded names", () => { + assert.equal( + fileUriOrAbsolutePath("file:///home/me/My%20Photos/cat%20hat.png"), + "/home/me/My Photos/cat hat.png", + ); +}); + +test("fileUriOrAbsolutePath strips the extra slash on Windows file URIs", () => { + assert.equal( + fileUriOrAbsolutePath("file:///C:/Users/me/Desktop/shot.jpg"), + "C:/Users/me/Desktop/shot.jpg", + ); +}); + +test("fileUriOrAbsolutePath accepts Windows drive and UNC paths", () => { + assert.equal( + fileUriOrAbsolutePath(String.raw`C:\Users\me\a.png`), + String.raw`C:\Users\me\a.png`, + ); + assert.equal( + fileUriOrAbsolutePath(String.raw`\\nas\share\photo.jpg`), + String.raw`\\nas\share\photo.jpg`, + ); +}); + +test("fileUriOrAbsolutePath rejects http(s) and relative paths", () => { + assert.equal(fileUriOrAbsolutePath("https://example.com/a.png"), null); + assert.equal(fileUriOrAbsolutePath("http://example.com/a.png"), null); + assert.equal(fileUriOrAbsolutePath("photos/cat.png"), null); + assert.equal(fileUriOrAbsolutePath("file:not-a-url"), null); + assert.equal(fileUriOrAbsolutePath(""), null); +}); + +test("extractPathsFromText skips uri-list comments and blanks", () => { + const text = [ + "# comment", + "file:///tmp/a.png", + "", + "/tmp/b.jpg", + "https://example.com/c.png", + ].join("\n"); + assert.deepEqual(extractPathsFromText(text), ["/tmp/a.png", "/tmp/b.jpg"]); +}); + +test("extractPathsFromText deduplicates", () => { + assert.deepEqual(extractPathsFromText("/tmp/a.png\n/tmp/a.png\n"), [ + "/tmp/a.png", + ]); +}); + +test("isOsFileDrag is true for Files and uri-list, not plain text", () => { + assert.equal(isOsFileDrag({ types: ["Files"] }), true); + assert.equal(isOsFileDrag({ types: ["text/uri-list", "text/plain"] }), true); + assert.equal(isOsFileDrag({ types: ["text/plain"] }), false); + assert.equal(isOsFileDrag({ types: ["text/html"] }), false); + assert.equal(isOsFileDrag(null), false); +}); + +test("extractDroppedFilePayload prefers File objects over path text", () => { + const file = { name: "a.png" }; + const data = { + files: [file], + getData: () => "file:///tmp/ignored.png", + }; + assert.deepEqual(extractDroppedFilePayload(data), { + files: [file], + paths: [], + }); +}); + +test("extractDroppedFilePayload recovers paths when files is empty", () => { + const data = { + files: [], + types: ["text/uri-list", "text/plain"], + getData: (type) => + type === "text/uri-list" + ? "file:///tmp/a.png\nfile:///tmp/b.jpg" + : "/tmp/a.png", + }; + assert.deepEqual(extractDroppedFilePayload(data), { + files: [], + paths: ["/tmp/a.png", "/tmp/b.jpg"], + }); +}); + +test("extractDroppedFilePayload is empty for ordinary text drags", () => { + const data = { + files: [], + types: ["text/plain"], + getData: () => "hello from another app", + }; + assert.deepEqual(extractDroppedFilePayload(data), { files: [], paths: [] }); +}); + +test("extractDroppedFilePayload ignores absolute paths without a file extension", () => { + const data = { + files: [], + types: ["text/plain"], + getData: () => "/usr/bin/env python\n/etc/passwd", + }; + assert.deepEqual(extractDroppedFilePayload(data), { files: [], paths: [] }); +}); + +test("looksLikeFileName requires a basename with an extension", () => { + assert.equal(looksLikeFileName("/tmp/photo.png"), true); + assert.equal(looksLikeFileName("/etc/passwd"), false); + assert.equal(looksLikeFileName("/usr/bin/env python"), false); +}); + +test("basenameFromPath handles unix and windows separators", () => { + assert.equal(basenameFromPath("/tmp/dir/photo.png"), "photo.png"); + assert.equal( + basenameFromPath(String.raw`C:\Users\me\photo.png`), + "photo.png", + ); + assert.equal(basenameFromPath("/"), "file"); +}); diff --git a/desktop/src/features/messages/lib/droppedFiles.ts b/desktop/src/features/messages/lib/droppedFiles.ts new file mode 100644 index 00000000000..27e220ca97e --- /dev/null +++ b/desktop/src/features/messages/lib/droppedFiles.ts @@ -0,0 +1,123 @@ +/** + * OS file-manager drops into the Tauri/WebKit composer. + * + * Linux WebKitGTK (and some other webviews) often omit `dataTransfer.files` + * and instead deliver `text/uri-list` / `text/plain` carrying `file://` URIs + * or absolute paths. ProseMirror then inserts that path as chat text unless + * `editorProps.handleDrop` claims the event. + */ + +export type DroppedFilePayload = { + files: File[]; + /** Absolute filesystem paths when the webview did not populate `File` objects. */ + paths: string[]; +}; + +function unique(values: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} + +/** Convert a `file://` URI or OS absolute path into a local path, or null. */ +export function fileUriOrAbsolutePath(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + if (/^https?:\/\//i.test(trimmed)) return null; + + if (/^file:\/\//i.test(trimmed)) { + try { + const url = new URL(trimmed); + if (url.protocol !== "file:") return null; + let path = decodeURIComponent(url.pathname); + // `file:///C:/Users/...` → `C:/Users/...` + if (/^\/[A-Za-z]:\//.test(path)) path = path.slice(1); + return path; + } catch { + return null; + } + } + + // Unix absolute, Windows drive, Windows UNC. + if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return trimmed; + if (/^[A-Za-z]:[\\/]/.test(trimmed)) return trimmed; + if (trimmed.startsWith("\\\\")) return trimmed; + return null; +} + +/** Parse `text/uri-list` or newline-separated plain text into local paths. */ +export function extractPathsFromText(text: string): string[] { + if (!text) return []; + const lines = text + .replace(/\r\n/g, "\n") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); + const paths: string[] = []; + for (const line of lines) { + const path = fileUriOrAbsolutePath(line); + if (path) paths.push(path); + } + return unique(paths); +} + +/** + * True when the drag payload is an OS file drop (not in-app text/mention + * drag). Used for overlay + preventDefault on dragover, where `getData` is + * often empty until drop. + */ +export function isOsFileDrag(data: DataTransfer | null | undefined): boolean { + if (!data) return false; + const types = Array.from(data.types ?? []); + return types.includes("Files") || types.includes("text/uri-list"); +} + +/** + * Prefer real `File` objects when the webview populated them. Otherwise + * recover absolute paths from URI-list / plain text so Linux drops still + * attach instead of inserting a path string into the composer. + */ +export function extractDroppedFilePayload( + data: DataTransfer | null | undefined, +): DroppedFilePayload { + if (!data) return { files: [], paths: [] }; + const files = Array.from(data.files ?? []); + if (files.length > 0) return { files, paths: [] }; + + const uriList = safeGetData(data, "text/uri-list"); + const plain = safeGetData(data, "text/plain"); + return { + files: [], + paths: unique([ + ...extractPathsFromText(uriList), + ...extractPathsFromText(plain), + ]).filter(looksLikeFileName), + }; +} + +export function basenameFromPath(path: string): string { + const parts = path.split(/[/\\]/); + const last = parts.at(-1)?.trim(); + return last && last.length > 0 ? last : "file"; +} + +/** Skip path-like chat text (`/usr/bin/env python`) that is not a dropped file. */ +export function looksLikeFileName(path: string): boolean { + const base = basenameFromPath(path); + const dot = base.lastIndexOf("."); + return dot > 0 && dot < base.length - 1; +} + +function safeGetData(data: DataTransfer, type: string): string { + try { + return data.getData(type) ?? ""; + } catch { + return ""; + } +} diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index 4b9d2c6cce7..fd3e539e10e 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -5,8 +5,13 @@ import { pickAndUploadMedia, uploadMediaBytes, } from "@/shared/api/tauri"; -import { uploadMediaFile } from "@/shared/api/tauriMedia"; +import { uploadDroppedMedia, uploadMediaFile } from "@/shared/api/tauriMedia"; import type { QueuedMediaAttachment } from "./backgroundMediaUploadStore"; +import { + basenameFromPath, + extractDroppedFilePayload, + isOsFileDrag, +} from "./droppedFiles"; import { applyImetaUpdate, compactImetaSlots } from "./imetaSlots"; import { useFilePicker } from "./useFilePicker"; import { isVideoFile, videoMimeForFile } from "./videoFileType"; @@ -50,10 +55,11 @@ function uploadProgressId(previewId: number): string { return `composer-upload-${previewId}`; } -/** True when the drag payload contains files (not plain text or URLs). */ -function isFileDrag(event: React.DragEvent): boolean { - return event.dataTransfer?.types.includes("Files") ?? false; -} +type ComposerFileDropEvent = { + dataTransfer: DataTransfer | null; + preventDefault: () => void; + stopPropagation: () => void; +}; function waitForMediaEvent( element: HTMLMediaElement, @@ -662,27 +668,60 @@ export function useMediaUpload({ uploadFiles, ]); + const uploadDroppedPaths = React.useCallback( + (paths: string[]) => { + if (paths.length === 0) return; + + setUploadingCount((count) => count + paths.length); + const baseIndex = reserveSlots(paths.length); + const epoch = uploadEpochRef.current; + + for (let index = 0; index < paths.length; index++) { + const path = paths[index]; + if (!path) continue; + const slotIndex = baseIndex + index; + const previewFile = new File([], basenameFromPath(path)); + const previewId = reserveUploadingPreview(previewFile, slotIndex); + void (async () => { + try { + const [descriptor] = await uploadDroppedMedia( + [path], + uploadProgressId(previewId), + ); + if (!descriptor) { + throw new Error("empty drop upload"); + } + fillSlot(slotIndex, descriptor, previewId, epoch); + } catch (err) { + onUploadError(err, previewId); + } + })(); + } + }, + [fillSlot, onUploadError, reserveSlots, reserveUploadingPreview], + ); + const handleDrop = React.useCallback( - async (event: React.DragEvent) => { + async (event: ComposerFileDropEvent) => { event.preventDefault(); + event.stopPropagation(); dragDepthRef.current = 0; setIsDragOver(false); - const files = Array.from(event.dataTransfer.files); - if (files.length === 0) return; + const { files, paths } = extractDroppedFilePayload(event.dataTransfer); + if (files.length === 0 && paths.length === 0) return; // Accept any file. The Tauri layer and the relay enforce the deny-list // (active-content + executables) and size caps; everything else uploads. - const validFiles = files; - - queueFiles(validFiles.filter(shouldQueueFile)); - uploadFiles(validFiles.filter((file) => !shouldQueueFile(file))); + queueFiles(files.filter(shouldQueueFile)); + uploadFiles(files.filter((file) => !shouldQueueFile(file))); + uploadDroppedPaths(paths); }, - [queueFiles, shouldQueueFile, uploadFiles], + [queueFiles, shouldQueueFile, uploadDroppedPaths, uploadFiles], ); const handleDragEnter = React.useCallback( (event: React.DragEvent) => { - if (!isFileDrag(event)) return; + if (!isOsFileDrag(event.dataTransfer)) return; event.preventDefault(); dragDepthRef.current += 1; if (dragDepthRef.current === 1) { @@ -694,7 +733,7 @@ export function useMediaUpload({ const handleDragLeave = React.useCallback( (event: React.DragEvent) => { - if (!isFileDrag(event)) return; + if (!isOsFileDrag(event.dataTransfer)) return; event.preventDefault(); dragDepthRef.current -= 1; if (dragDepthRef.current <= 0) { diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index e77e2e0d553..f178586d856 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -765,6 +765,9 @@ function MessageComposerImpl({ ); useComposerPasteHandler({ editor: richText.editor, + onFileDrop: (event) => { + void media.handleDrop(event); + }, scrollToBottom: scrollComposerToBottom, setPendingImeta: media.setPendingImeta, uploadFile: media.uploadFile, diff --git a/desktop/src/features/messages/ui/useComposerPasteHandler.ts b/desktop/src/features/messages/ui/useComposerPasteHandler.ts index 8e56da071a9..be9cb1b477d 100644 --- a/desktop/src/features/messages/ui/useComposerPasteHandler.ts +++ b/desktop/src/features/messages/ui/useComposerPasteHandler.ts @@ -1,6 +1,10 @@ import * as React from "react"; import type { Editor } from "@tiptap/react"; import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; +import { + extractDroppedFilePayload, + isOsFileDrag, +} from "@/features/messages/lib/droppedFiles"; import type { BlobDescriptor } from "@/shared/api/tauri"; import { hasMentionClipboardHtml, @@ -10,6 +14,7 @@ import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard"; export function useComposerPasteHandler(options: { editor: Editor | null; + onFileDrop: (event: DragEvent) => void; scrollToBottom: () => void; setPendingImeta: ( update: (current: BlobDescriptor[]) => BlobDescriptor[], @@ -18,12 +23,28 @@ export function useComposerPasteHandler(options: { }) { const uploadFileRef = React.useRef(options.uploadFile); uploadFileRef.current = options.uploadFile; + const onFileDropRef = React.useRef(options.onFileDrop); + onFileDropRef.current = options.onFileDrop; React.useEffect(() => { const editor = options.editor; if (!editor) return; editor.setOptions({ editorProps: { ...editor.options.editorProps, + // Claim OS file drops before ProseMirror inserts the path as text. + handleDrop: (_view, event) => { + const dragEvent = event as DragEvent; + const payload = extractDroppedFilePayload(dragEvent.dataTransfer); + const isFileDrop = + isOsFileDrag(dragEvent.dataTransfer) || + payload.files.length > 0 || + payload.paths.length > 0; + if (!isFileDrop) return false; + dragEvent.preventDefault(); + dragEvent.stopPropagation(); + onFileDropRef.current(dragEvent); + return true; + }, handlePaste: (view, event) => { const mediaItem = Array.from(event.clipboardData?.items ?? []).find( (item) => item.kind === "file", diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index 2f0498769ff..08b23f493b7 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -45,6 +45,23 @@ export async function releaseMediaUpload(progressId: string): Promise { await invokeTauri("release_media_upload", { progressId }); } +/** + * Upload files the user dropped from the OS file manager. + * + * Used when the webview populates path/`file://` text instead of `File` + * objects (common on Linux WebKitGTK). Paths are opened in Rust through the + * same TOCTOU-safe pipeline as the paperclip picker. + */ +export async function uploadDroppedMedia( + paths: string[], + progressId?: string, +): Promise { + return invokeTauri("upload_dropped_media", { + paths, + progressId: progressId ?? null, + }); +} + /** * Open a native single-file picker constrained to images and upload the * chosen file. Non-image files are rejected in Rust (via MIME sniffing) From 6b2910c4f19cafe8951214d73a7ff60a51e3df2c Mon Sep 17 00:00:00 2001 From: Muammer Date: Thu, 27 Aug 2026 16:09:37 +0300 Subject: [PATCH 2/2] fix(desktop): recover GNOME Files drops via GTK on Linux WebKitGTK on GNOME/Wayland advertises Files on dragover then delivers an empty dataTransfer on drop. Ignore dummy File objects, set dropEffect=copy, and read the real paths from GTK drag-data-received. Signed-off-by: Muammer --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 3 + desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/src/linux_file_drop.rs | 164 ++++++++++++++++++ .../src/app/useAppShellLifecycleEffects.ts | 5 + .../messages/lib/droppedFiles.test.mjs | 33 +++- .../src/features/messages/lib/droppedFiles.ts | 118 +++++++++++-- .../features/messages/lib/osFileDropBus.ts | 87 ++++++++++ .../features/messages/lib/useMediaUpload.ts | 26 ++- 9 files changed, 421 insertions(+), 18 deletions(-) create mode 100644 desktop/src-tauri/src/linux_file_drop.rs create mode 100644 desktop/src/features/messages/lib/osFileDropBus.ts diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 68b702431af..30d9517c78e 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1108,6 +1108,7 @@ dependencies = [ "flate2", "futures-util", "getrandom 0.2.17", + "gtk", "hex", "image", "infer", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 2255a418603..59c839a0f12 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -49,6 +49,9 @@ notify-rust = "4" # to the exact version wry links so both resolve to one webkit2gtk-sys and we # don't get duplicate symbols; bump in lockstep with wry. webkit2gtk = { version = "=2.0.2", features = ["v2_22"] } +# Same gtk as wry/webkit2gtk. Used to read GNOME Files drop URIs from the +# GTK widget because WebKitGTK leaves dataTransfer.files empty. +gtk = "0.18" [target.'cfg(target_os = "macos")'.dependencies] block2 = { version = "0.6", default-features = false, features = ["std"] } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 798141c105c..2f7726492a4 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ mod identity_storage; mod initial_window; mod key_backup; mod link_preview_tags; +mod linux_file_drop; mod linux_media; #[cfg(target_os = "macos")] mod macos_notifications; @@ -155,6 +156,7 @@ pub fn run() { // permission-request handler for getUserMedia; no-op // on macOS/Windows. linux_media::enable_media_capture(&webview); + linux_file_drop::enable_os_file_drop(&webview); // macOS applies the restored geometry asynchronously. Wait // for several identical outer bounds and for React to diff --git a/desktop/src-tauri/src/linux_file_drop.rs b/desktop/src-tauri/src/linux_file_drop.rs new file mode 100644 index 00000000000..e4600500eff --- /dev/null +++ b/desktop/src-tauri/src/linux_file_drop.rs @@ -0,0 +1,164 @@ +//! Linux-only: recover OS file-manager drop paths from GTK. +//! +//! WebKitGTK often advertises `Files` / `text/uri-list` on dragover so the +//! composer overlay shows, then delivers an empty `dataTransfer` on drop +//! (GNOME Files + Wayland). The URI list still arrives on the GTK widget +//! via `drag-data-received`. We stash those paths and emit `os-file-drop` +//! only on `drag-drop` (button release), leaving HTML5 DnD enabled +//! (`dragDropEnabled: false`) so Windows/macOS File-object drops are unchanged. + +use serde::Serialize; + +#[derive(Clone, Debug, Serialize)] +struct OsFileDropPayload { + paths: Vec, +} + +/// Convert a `file://` URI or Unix absolute path into a local path. +/// +/// Platform-independent so unit tests run everywhere. Rejects http(s) and +/// non-localhost file hosts. +pub fn file_uri_to_path(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + if trimmed.starts_with('/') && !trimmed.starts_with("//") { + return Some(trimmed.to_string()); + } + + let parsed = url::Url::parse(trimmed).ok()?; + if parsed.scheme() != "file" { + return None; + } + let host = parsed.host_str().unwrap_or(""); + if !host.is_empty() && host != "localhost" { + return None; + } + parsed + .to_file_path() + .ok() + .and_then(|path| path.to_str().map(str::to_string)) +} + +/// Attach GTK listeners that emit `os-file-drop` when the user releases. +/// +/// `drag-leave` fires on the way into a child widget *and* on a real leave, +/// so we defer the clear to an idle callback the same way wry does. A +/// following `drag-drop` still sees the stashed paths. +#[cfg(target_os = "linux")] +pub fn enable_os_file_drop(webview: &tauri::Webview) { + use gtk::prelude::WidgetExt; + use std::cell::{Cell, RefCell}; + use std::rc::Rc; + use tauri::{Emitter, Manager}; + + #[derive(Clone, Copy, PartialEq, Eq)] + enum DragState { + Entered, + Leaving, + Left, + } + + let handle = webview.app_handle().clone(); + let result = webview.with_webview(move |platform_webview| { + let gtk_webview = platform_webview.inner(); + // Ask GTK to fetch URI-list/text in addition to WebKit's HTML5 dest + // so `drag-data-received` actually sees GNOME Files paths. + gtk_webview.drag_dest_add_uri_targets(); + gtk_webview.drag_dest_add_text_targets(); + let pending = Rc::new(RefCell::new(Vec::::new())); + let state = Rc::new(Cell::new(DragState::Left)); + + { + let pending = pending.clone(); + let state = state.clone(); + gtk_webview.connect_drag_data_received(move |_, _, _, _, data, _, _| { + let mut paths: Vec = data + .uris() + .iter() + .filter_map(|uri| file_uri_to_path(uri.as_str())) + .collect(); + if paths.is_empty() { + if let Some(text) = data.text() { + paths = text.lines().filter_map(file_uri_to_path).collect(); + } + } + *pending.borrow_mut() = paths; + if state.get() != DragState::Entered { + state.set(DragState::Entered); + } + }); + } + + { + let pending = pending.clone(); + let state = state.clone(); + let handle = handle.clone(); + gtk_webview.connect_drag_drop(move |_, _, _, _, _| { + let paths = std::mem::take(&mut *pending.borrow_mut()); + state.set(DragState::Left); + if !paths.is_empty() { + let _ = handle.emit("os-file-drop", OsFileDropPayload { paths }); + } + false + }); + } + + { + let pending = pending.clone(); + let state = state.clone(); + gtk_webview.connect_drag_leave(move |_, _, _| { + if state.get() == DragState::Left { + return; + } + state.set(DragState::Leaving); + let pending = pending.clone(); + let state = state.clone(); + gtk::glib::idle_add_local_once(move || { + if state.get() == DragState::Leaving { + pending.borrow_mut().clear(); + state.set(DragState::Left); + } + }); + }); + } + }); + + if let Err(error) = result { + eprintln!("buzz-desktop: could not attach WebKitGTK file-drop listener: {error}"); + } +} + +#[cfg(not(target_os = "linux"))] +pub fn enable_os_file_drop(_webview: &tauri::Webview) {} + +#[cfg(test)] +mod tests { + use super::file_uri_to_path; + + #[test] + fn accepts_file_uri_and_absolute_path() { + assert_eq!( + file_uri_to_path("file:///tmp/photo.png").as_deref(), + Some("/tmp/photo.png") + ); + assert_eq!( + file_uri_to_path("/tmp/photo.png").as_deref(), + Some("/tmp/photo.png") + ); + assert_eq!( + file_uri_to_path("file://localhost/tmp/photo.png").as_deref(), + Some("/tmp/photo.png") + ); + } + + #[test] + fn rejects_http_and_remote_hosts() { + assert_eq!(file_uri_to_path("https://example.com/a.png"), None); + assert_eq!(file_uri_to_path("file://nas/share/a.png"), None); + assert_eq!(file_uri_to_path(""), None); + assert_eq!(file_uri_to_path("# comment"), None); + } +} diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index ea8a17ca290..41143338172 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -31,6 +31,11 @@ export function useAppShellLifecycleEffects({ const types = Array.from(e.dataTransfer?.types ?? []); if (types.includes("Files") || types.includes("text/uri-list")) { e.preventDefault(); + try { + if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; + } catch { + // WebKitGTK may freeze dropEffect; preventDefault still matters. + } } } window.addEventListener("dragover", preventNavigation); diff --git a/desktop/src/features/messages/lib/droppedFiles.test.mjs b/desktop/src/features/messages/lib/droppedFiles.test.mjs index 8a7d3d37e14..1dc7ac9f25d 100644 --- a/desktop/src/features/messages/lib/droppedFiles.test.mjs +++ b/desktop/src/features/messages/lib/droppedFiles.test.mjs @@ -82,10 +82,11 @@ test("isOsFileDrag is true for Files and uri-list, not plain text", () => { assert.equal(isOsFileDrag(null), false); }); -test("extractDroppedFilePayload prefers File objects over path text", () => { - const file = { name: "a.png" }; +test("extractDroppedFilePayload prefers usable File objects over path text", () => { + const file = { name: "a.png", size: 12 }; const data = { files: [file], + types: ["Files", "text/uri-list"], getData: () => "file:///tmp/ignored.png", }; assert.deepEqual(extractDroppedFilePayload(data), { @@ -94,6 +95,34 @@ test("extractDroppedFilePayload prefers File objects over path text", () => { }); }); +test("extractDroppedFilePayload ignores dummy File objects and reads paths", () => { + const dummy = { name: "", size: 0 }; + const data = { + files: [dummy], + types: ["Files", "text/uri-list"], + getData: (type) => (type === "text/uri-list" ? "file:///tmp/a.png" : ""), + }; + assert.deepEqual(extractDroppedFilePayload(data), { + files: [], + paths: ["/tmp/a.png"], + }); +}); + +test("extractDroppedFilePayload reads GNOME copied-files lists", () => { + const data = { + files: [], + types: ["x-special/gnome-copied-files"], + getData: (type) => + type === "x-special/gnome-copied-files" + ? "copy\nfile:///tmp/a.png" + : "", + }; + assert.deepEqual(extractDroppedFilePayload(data), { + files: [], + paths: ["/tmp/a.png"], + }); +}); + test("extractDroppedFilePayload recovers paths when files is empty", () => { const data = { files: [], diff --git a/desktop/src/features/messages/lib/droppedFiles.ts b/desktop/src/features/messages/lib/droppedFiles.ts index 27e220ca97e..869844ce5a1 100644 --- a/desktop/src/features/messages/lib/droppedFiles.ts +++ b/desktop/src/features/messages/lib/droppedFiles.ts @@ -5,6 +5,11 @@ * and instead deliver `text/uri-list` / `text/plain` carrying `file://` URIs * or absolute paths. ProseMirror then inserts that path as chat text unless * `editorProps.handleDrop` claims the event. + * + * WebKitGTK also sometimes fills `files` with dummy `File` objects (`size` + * 0, empty name). Those must not win over URI-list paths, or the drop is + * claimed and then silently discarded. On GNOME Files + Wayland even the + * URI-list is empty; the GTK `os-file-drop` event supplies the real paths. */ export type DroppedFilePayload = { @@ -13,6 +18,22 @@ export type DroppedFilePayload = { paths: string[]; }; +/** MIME types that mean "this drag is files", not in-app text. */ +const FILE_DRAG_MIME_TYPES = [ + "Files", + "text/uri-list", + "text/x-moz-url", + "x-special/gnome-copied-files", + "application/x-kde4-urilist", +] as const; + +/** Extra MIME types that may carry paths on drop. `text/plain` is not a + * drag signal — in-app text/mention drags use it. */ +const PATH_MIME_TYPES = [ + ...FILE_DRAG_MIME_TYPES.filter((type) => type !== "Files"), + "text/plain", +] as const; + function unique(values: string[]): string[] { const seen = new Set(); const out: string[] = []; @@ -31,15 +52,26 @@ export function fileUriOrAbsolutePath(value: string): string | null { if (/^https?:\/\//i.test(trimmed)) return null; - if (/^file:\/\//i.test(trimmed)) { + // Require `file:/` so `file:not-a-url` is not parsed as `/not-a-url`. + if (/^file:\//i.test(trimmed)) { try { const url = new URL(trimmed); if (url.protocol !== "file:") return null; + const host = url.hostname.toLowerCase(); + if (host && host !== "localhost") return null; let path = decodeURIComponent(url.pathname); // `file:///C:/Users/...` → `C:/Users/...` if (/^\/[A-Za-z]:\//.test(path)) path = path.slice(1); return path; } catch { + const withoutScheme = trimmed.replace(/^file:/i, ""); + if (withoutScheme.startsWith("/")) { + try { + return decodeURIComponent(withoutScheme); + } catch { + return withoutScheme; + } + } return null; } } @@ -56,6 +88,7 @@ export function extractPathsFromText(text: string): string[] { if (!text) return []; const lines = text .replace(/\r\n/g, "\n") + .replace(/\0/g, "\n") .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith("#")); @@ -67,6 +100,10 @@ export function extractPathsFromText(text: string): string[] { return unique(paths); } +function mimeTypes(data: DataTransfer): string[] { + return Array.from(data.types ?? []); +} + /** * True when the drag payload is an OS file drop (not in-app text/mention * drag). Used for overlay + preventDefault on dragover, where `getData` is @@ -74,31 +111,84 @@ export function extractPathsFromText(text: string): string[] { */ export function isOsFileDrag(data: DataTransfer | null | undefined): boolean { if (!data) return false; - const types = Array.from(data.types ?? []); - return types.includes("Files") || types.includes("text/uri-list"); + const types = mimeTypes(data); + return FILE_DRAG_MIME_TYPES.some((type) => types.includes(type)); +} + +/** Mark the drag as a copy so WebKitGTK actually fires `drop`. */ +export function markOsFileDragOver(data: DataTransfer | null | undefined): void { + if (!data || !isOsFileDrag(data)) return; + try { + data.dropEffect = "copy"; + } catch { + // Some webviews freeze dropEffect during dragover. + } +} + +function isUsableDroppedFile(file: File | null | undefined): file is File { + return Boolean(file && file.size > 0 && file.name); +} + +function filesFromList(list: ArrayLike | null | undefined): File[] { + return Array.from(list ?? []).filter(isUsableDroppedFile); +} + +function filesFromItems(data: DataTransfer): File[] { + const items = data.items; + if (!items) return []; + const files: File[] = []; + try { + for (let index = 0; index < items.length; index++) { + const item = items[index]; + if (!item || item.kind !== "file") continue; + const file = item.getAsFile(); + if (isUsableDroppedFile(file)) files.push(file); + } + } catch { + // WebKitGTK can throw when enumerating items after the drop event. + } + return files; +} + +function extractPathsFromDataTransfer(data: DataTransfer): string[] { + const types = unique([...mimeTypes(data), ...PATH_MIME_TYPES]); + const paths: string[] = []; + for (const type of types) { + if (type === "Files") continue; + paths.push(...extractPathsFromText(safeGetData(data, type))); + } + return unique(paths).filter(looksLikeFileName); +} + +function uniqueFiles(files: File[]): File[] { + const seen = new Set(); + const out: File[] = []; + for (const file of files) { + const key = `${file.name}:${file.size}:${file.type}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(file); + } + return out; } /** * Prefer real `File` objects when the webview populated them. Otherwise * recover absolute paths from URI-list / plain text so Linux drops still * attach instead of inserting a path string into the composer. + * + * Dummy `File` entries (`size` 0) are ignored so URI-list paths still win. */ export function extractDroppedFilePayload( data: DataTransfer | null | undefined, ): DroppedFilePayload { if (!data) return { files: [], paths: [] }; - const files = Array.from(data.files ?? []); + const files = uniqueFiles([ + ...filesFromList(data.files), + ...filesFromItems(data), + ]); if (files.length > 0) return { files, paths: [] }; - - const uriList = safeGetData(data, "text/uri-list"); - const plain = safeGetData(data, "text/plain"); - return { - files: [], - paths: unique([ - ...extractPathsFromText(uriList), - ...extractPathsFromText(plain), - ]).filter(looksLikeFileName), - }; + return { files: [], paths: extractPathsFromDataTransfer(data) }; } export function basenameFromPath(path: string): string { diff --git a/desktop/src/features/messages/lib/osFileDropBus.ts b/desktop/src/features/messages/lib/osFileDropBus.ts new file mode 100644 index 00000000000..120e9c3b934 --- /dev/null +++ b/desktop/src/features/messages/lib/osFileDropBus.ts @@ -0,0 +1,87 @@ +/** + * Linux WebKitGTK often fires HTML5 `drop` with an empty payload, then + * (or just before) the Rust GTK listener emits `os-file-drop` with real + * paths. This bus hands those paths to the composer that last showed the + * drop overlay, and dedupes the HTML5 + GTK double-delivery. + */ + +import { looksLikeFileName } from "./droppedFiles"; + +type UploadPaths = (paths: string[]) => void; + +let activeUpload: UploadPaths | null = null; +let pending: string[] = []; +let lastKey = ""; +let lastAt = 0; +let listening = false; + +function unique(values: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} + +function usable(paths: string[]): string[] { + return unique(paths.filter(looksLikeFileName)); +} + +function takeIfFresh(paths: string[]): string[] { + const next = usable(paths); + if (next.length === 0) return []; + const key = next.join("\0"); + const now = Date.now(); + if (key === lastKey && now - lastAt < 800) return []; + lastKey = key; + lastAt = now; + return next; +} + +/** The composer currently under an OS file drag. */ +export function noteOsFileDropTarget(upload: UploadPaths | null): void { + activeUpload = upload; +} + +/** Paths from the GTK `os-file-drop` event. */ +export function receiveOsDropPaths(paths: string[]): void { + const next = usable(paths); + if (next.length === 0) return; + if (activeUpload) { + const fresh = takeIfFresh(next); + if (fresh.length > 0) activeUpload(fresh); + pending = []; + return; + } + pending = next; +} + +/** Consume paths stashed because HTML5 drop ran before the GTK event. */ +export function takePendingOsDropPaths(): string[] { + const out = pending; + pending = []; + return takeIfFresh(out); +} + +/** Dedupe HTML5 path recovery against the GTK event. */ +export function consumeDropPaths(paths: string[]): string[] { + return takeIfFresh(paths); +} + +/** One window-level listener for all composer instances. */ +export function ensureOsDropListener(): void { + if (listening) return; + listening = true; + void import("@tauri-apps/api/event") + .then(({ listen }) => + listen<{ paths: string[] }>("os-file-drop", (event) => { + receiveOsDropPaths(event.payload.paths ?? []); + }), + ) + .catch(() => { + listening = false; + }); +} diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index fd3e539e10e..08e8700c66a 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -11,7 +11,14 @@ import { basenameFromPath, extractDroppedFilePayload, isOsFileDrag, + markOsFileDragOver, } from "./droppedFiles"; +import { + consumeDropPaths, + ensureOsDropListener, + noteOsFileDropTarget, + takePendingOsDropPaths, +} from "./osFileDropBus"; import { applyImetaUpdate, compactImetaSlots } from "./imetaSlots"; import { useFilePicker } from "./useFilePicker"; import { isVideoFile, videoMimeForFile } from "./videoFileType"; @@ -241,6 +248,9 @@ export function useMediaUpload({ // ── Drag-over visual indicator state ─────────────────────────────── const [isDragOver, setIsDragOver] = React.useState(false); + React.useEffect(() => { + ensureOsDropListener(); + }, []); /** Tracks nested dragenter/dragleave pairs so we only flip `isDragOver` * when the pointer truly enters or leaves the drop target. */ const dragDepthRef = React.useRef(0); @@ -707,7 +717,15 @@ export function useMediaUpload({ event.stopPropagation(); dragDepthRef.current = 0; setIsDragOver(false); - const { files, paths } = extractDroppedFilePayload(event.dataTransfer); + const extracted = extractDroppedFilePayload(event.dataTransfer); + const files = extracted.files; + let paths = extracted.paths; + if (files.length === 0 && paths.length === 0) { + // WebKitGTK: HTML5 payload is empty; GTK `os-file-drop` has the paths. + paths = takePendingOsDropPaths(); + } else { + paths = consumeDropPaths(paths); + } if (files.length === 0 && paths.length === 0) return; // Accept any file. The Tauri layer and the relay enforce the deny-list @@ -723,12 +741,14 @@ export function useMediaUpload({ (event: React.DragEvent) => { if (!isOsFileDrag(event.dataTransfer)) return; event.preventDefault(); + markOsFileDragOver(event.dataTransfer); + noteOsFileDropTarget(uploadDroppedPaths); dragDepthRef.current += 1; if (dragDepthRef.current === 1) { setIsDragOver(true); } }, - [], + [uploadDroppedPaths], ); const handleDragLeave = React.useCallback( @@ -746,7 +766,9 @@ export function useMediaUpload({ const handleDragOver = React.useCallback( (event: React.DragEvent) => { + if (!isOsFileDrag(event.dataTransfer)) return; event.preventDefault(); + markOsFileDragOver(event.dataTransfer); }, [], );