use gtk::gdk;
use gtk::prelude::*;
use gtk::{gio, glib};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::time::Duration;
use vte::prelude::*;
use crate::terminal::shell_quote;
const MAX_DROPPED_FILES: usize = 256;
const MAX_DROPPED_TEXT_BYTES: usize = 1024 * 1024;
const DROP_READ_CHUNK_BYTES: usize = 64 * 1024;
const DROP_READ_TIMEOUT: Duration = Duration::from_secs(15);
struct DropReadRequest {
drop: gdk::Drop,
terminal: glib::WeakRef<vte::Terminal>,
generation: u64,
current_generation: Rc<Cell<u64>>,
cancellable: gio::Cancellable,
timeout: RefCell<Option<glib::SourceId>>,
finished: Cell<bool>,
}
impl DropReadRequest {
fn new(
drop: &gdk::Drop,
terminal: glib::WeakRef<vte::Terminal>,
generation: u64,
current_generation: Rc<Cell<u64>>,
) -> Rc<Self> {
let request = Rc::new(Self {
drop: drop.clone(),
terminal,
generation,
current_generation,
cancellable: gio::Cancellable::new(),
timeout: RefCell::new(None),
finished: Cell::new(false),
});
let timeout = glib::timeout_add_local_once(DROP_READ_TIMEOUT, {
let request = Rc::downgrade(&request);
move || {
if let Some(request) = request.upgrade() {
request.timeout.borrow_mut().take();
request.cancel();
}
}
});
request.timeout.replace(Some(timeout));
request
}
fn start(self: &Rc<Self>) {
let request = self.clone();
self.drop.read_async(
&["text/uri-list"],
glib::Priority::DEFAULT,
Some(&self.cancellable),
move |result| match result {
Ok((stream, _)) => request.read_chunk(stream, Vec::new()),
Err(_) => request.finish(gdk::DragAction::empty()),
},
);
}
fn read_chunk(self: &Rc<Self>, stream: gio::InputStream, bytes: Vec<u8>) {
if self.finished.get() {
return;
}
let Some(remaining) = (MAX_DROPPED_TEXT_BYTES + 1).checked_sub(bytes.len()) else {
self.finish(gdk::DragAction::empty());
return;
};
let read_size = remaining.clamp(1, DROP_READ_CHUNK_BYTES);
let request = self.clone();
let next_stream = stream.clone();
stream.read_bytes_async(
read_size,
glib::Priority::DEFAULT,
Some(&self.cancellable),
move |result| {
let Ok(chunk) = result else {
request.finish(gdk::DragAction::empty());
return;
};
if chunk.is_empty() {
request.complete(bytes);
return;
}
let mut bytes = bytes;
bytes.extend_from_slice(chunk.as_ref());
if bytes.len() > MAX_DROPPED_TEXT_BYTES {
request.finish(gdk::DragAction::empty());
} else {
request.read_chunk(next_stream, bytes);
}
},
);
}
fn complete(&self, bytes: Vec<u8>) {
if self.finished.get() {
return;
}
let accepted = String::from_utf8(bytes)
.ok()
.and_then(|uri_list| dropped_uri_list_text(&uri_list))
.and_then(|text| {
let terminal = self.terminal.upgrade()?;
let window_active = terminal
.root()
.and_then(|root| root.downcast::<gtk::Window>().ok())
.is_some_and(|window| window.is_active());
if !drop_request_is_authorized(
self.generation,
self.current_generation.get(),
terminal.is_mapped(),
window_active,
) {
return None;
}
terminal.paste_text(&text);
Some(())
})
.is_some();
self.finish(if accepted {
gdk::DragAction::COPY
} else {
gdk::DragAction::empty()
});
}
fn cancel(&self) {
self.cancellable.cancel();
self.finish(gdk::DragAction::empty());
}
fn finish(&self, action: gdk::DragAction) {
if self.finished.replace(true) {
return;
}
if let Some(timeout) = self.timeout.borrow_mut().take() {
timeout.remove();
}
self.drop.finish(action);
}
}
pub(crate) fn install_file_drop(terminal: &vte::Terminal) {
let formats = gdk::ContentFormats::new(&["text/uri-list"]);
let target = gtk::DropTargetAsync::new(Some(formats), gdk::DragAction::COPY);
let terminal_ref = terminal.downgrade();
let drop_generation = Rc::new(Cell::new(0_u64));
let active_request = Rc::new(RefCell::<Option<Rc<DropReadRequest>>>::new(None));
target.connect_drag_enter({
let terminal = terminal_ref.clone();
move |_, _, _, _| {
if let Some(terminal) = terminal.upgrade() {
terminal.add_css_class("lios-drop-ready");
}
gdk::DragAction::COPY
}
});
target.connect_drag_leave({
let terminal = terminal_ref.clone();
move |_, _| {
if let Some(terminal) = terminal.upgrade() {
terminal.remove_css_class("lios-drop-ready");
}
}
});
target.connect_drop({
let active_request = active_request.clone();
move |_, drop, _, _| {
if terminal_ref.upgrade().is_none() {
return false;
}
if let Some(previous) = active_request.borrow_mut().take() {
previous.cancel();
}
let generation = drop_generation.get().wrapping_add(1);
drop_generation.set(generation);
let request = DropReadRequest::new(
drop,
terminal_ref.clone(),
generation,
drop_generation.clone(),
);
request.start();
active_request.replace(Some(request));
true
}
});
terminal.connect_destroy(move |_| {
if let Some(request) = active_request.borrow_mut().take() {
request.cancel();
}
});
terminal.add_controller(target);
}
fn drop_request_is_authorized(
request_generation: u64,
current_generation: u64,
terminal_mapped: bool,
window_active: bool,
) -> bool {
request_generation == current_generation && terminal_mapped && window_active
}
fn dropped_uri_list_text(uri_list: &str) -> Option<String> {
let mut files = Vec::new();
for line in uri_list.lines().map(str::trim) {
if line.is_empty() || line.starts_with('#') {
continue;
}
if files.len() == MAX_DROPPED_FILES
|| line.contains('\0')
|| glib::Uri::is_valid(line, glib::UriFlags::NONE).is_err()
{
return None;
}
files.push(gio::File::for_uri(line));
}
dropped_file_text(files.iter())
}
fn dropped_file_text<'a>(files: impl IntoIterator<Item = &'a gio::File>) -> Option<String> {
let mut text = String::new();
let mut count = 0;
for file in files {
count += 1;
if count > MAX_DROPPED_FILES {
return None;
}
let value = match file.path() {
Some(path) => path.into_os_string().into_string().ok()?,
None => file.uri().to_string(),
};
if value.chars().any(char::is_control) {
return None;
}
let quoted = shell_quote(&value);
let separator_bytes = usize::from(!text.is_empty());
if text
.len()
.saturating_add(separator_bytes)
.saturating_add(quoted.len())
> MAX_DROPPED_TEXT_BYTES
{
return None;
}
if !text.is_empty() {
text.push(' ');
}
text.push_str("ed);
}
(!text.is_empty()).then_some(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dropped_files_are_shell_quoted_and_space_separated() {
let files = [
gio::File::for_path("/tmp/ordinary.txt"),
gio::File::for_path("/tmp/a file's name; $(touch nope)"),
];
assert_eq!(
dropped_file_text(files.iter()).as_deref(),
Some("'/tmp/ordinary.txt' '/tmp/a file'\\''s name; $(touch nope)'")
);
}
#[test]
fn remote_files_fall_back_to_quoted_uris() {
let files = [gio::File::for_uri("sftp://example.test/home/a%20file.txt")];
assert_eq!(
dropped_file_text(files.iter()).as_deref(),
Some("'sftp://example.test/home/a%20file.txt'")
);
}
#[test]
fn uri_lists_ignore_comments_and_decode_local_paths() {
assert_eq!(
dropped_uri_list_text(
"# generated by file manager\r\nfile:///tmp/one%20file\r\n\r\nfile:///tmp/two\n"
)
.as_deref(),
Some("'/tmp/one file' '/tmp/two'")
);
}
#[test]
fn malformed_uri_lists_are_rejected_as_a_unit() {
assert_eq!(dropped_uri_list_text("file:///tmp/good\nnot a URI\n"), None);
assert_eq!(dropped_uri_list_text("file:///tmp/good\0bad\n"), None);
}
#[test]
fn terminal_control_characters_in_local_paths_are_rejected() {
for path in [
"/tmp/line\nbreak",
"/tmp/readline\u{15}erase",
"/tmp/bracket\u{1b}[201~escape",
"/tmp/tab\tname",
] {
let files = [gio::File::for_path(path)];
assert_eq!(dropped_file_text(files.iter()), None, "path: {path:?}");
}
assert_eq!(
dropped_uri_list_text("file:///tmp/bracket%1B%5B201~%15echo%20nope%0A"),
None
);
}
#[test]
fn non_utf8_local_paths_are_rejected_instead_of_changed_to_uris() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let path = OsString::from_vec(b"/tmp/non-utf8-\xff".to_vec());
let files = [gio::File::for_path(path)];
assert_eq!(dropped_file_text(files.iter()), None);
}
#[test]
fn asynchronous_drop_requires_the_current_mapped_target() {
assert!(drop_request_is_authorized(4, 4, true, true));
assert!(!drop_request_is_authorized(3, 4, true, true));
assert!(!drop_request_is_authorized(4, 4, false, true));
assert!(!drop_request_is_authorized(4, 4, true, false));
}
#[test]
fn empty_and_excessive_drops_are_rejected() {
let empty: [gio::File; 0] = [];
assert_eq!(dropped_file_text(empty.iter()), None);
let files = (0..=MAX_DROPPED_FILES)
.map(|index| gio::File::for_path(format!("/tmp/{index}")))
.collect::<Vec<_>>();
assert_eq!(dropped_file_text(files.iter()), None);
}
#[test]
fn oversized_drop_text_is_rejected_before_pasting() {
let oversized = format!("/tmp/{}", "x".repeat(MAX_DROPPED_TEXT_BYTES));
let files = [gio::File::for_path(oversized)];
assert_eq!(dropped_file_text(files.iter()), None);
}
}