lios 0.1.74

A gorgeous GTK4/VTE Linux terminal with live themes, glass backgrounds, session prompt profiles, Sixel images, and safe GPU controls.
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(&quoted);
    }

    (!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);
    }
}