Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
71 changes: 71 additions & 0 deletions desktop/src-tauri/src/commands/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::path::PathBuf, String> {
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<String>,
progress_id: Option<String>,
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<Vec<BlobDescriptor>, 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<u8>,
filename: Option<String>,
Expand Down Expand Up @@ -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");
Expand Down
3 changes: 3 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -663,6 +665,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,
Expand Down
164 changes: 164 additions & 0 deletions desktop/src-tauri/src/linux_file_drop.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

/// 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<String> {
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<R: tauri::Runtime>(webview: &tauri::Webview<R>) {
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::<String>::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<String> = 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<R: tauri::Runtime>(_webview: &tauri::Webview<R>) {}

#[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);
}
}
8 changes: 7 additions & 1 deletion desktop/src/app/useAppShellLifecycleEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,14 @@ 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();
try {
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
} catch {
// WebKitGTK may freeze dropEffect; preventDefault still matters.
}
}
}
window.addEventListener("dragover", preventNavigation);
Expand Down
19 changes: 19 additions & 0 deletions desktop/src/features/forum/ui/ForumComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -352,13 +356,28 @@ 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;

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
Expand Down
Loading
Loading