lios 0.1.62

A gorgeous GTK4/VTE Linux terminal with live themes, glass backgrounds, session prompt profiles, Sixel images, and safe GPU controls.
use gtk::prelude::*;
use gtk::{gio, glib};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use vte::prelude::*;

const LARGE_PASTE_BYTES: usize = 4096;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PasteMetadata {
    line_break_count: usize,
    line_count: usize,
    character_count: usize,
    byte_count: usize,
    disallowed_control_count: usize,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PasteAssessment {
    metadata: PasteMetadata,
    unusually_large: bool,
}

impl PasteAssessment {
    fn requires_confirmation(self) -> bool {
        self.metadata.line_break_count > 0
            || self.metadata.disallowed_control_count > 0
            || self.unusually_large
    }
}

#[derive(Clone)]
pub(crate) struct PasteController {
    inner: Rc<PasteControllerInner>,
}

struct PasteControllerInner {
    window: glib::WeakRef<gtk::ApplicationWindow>,
    terminal: glib::WeakRef<vte::Terminal>,
    pending_request: Cell<Option<u64>>,
    next_request: Cell<u64>,
    cancellable: RefCell<Option<(u64, gio::Cancellable)>>,
    terminal_focused: Cell<bool>,
}

impl PasteController {
    pub(crate) fn new(window: &gtk::ApplicationWindow, terminal: &vte::Terminal) -> Self {
        let inner = Rc::new(PasteControllerInner {
            window: window.downgrade(),
            terminal: terminal.downgrade(),
            pending_request: Cell::new(None),
            next_request: Cell::new(0),
            cancellable: RefCell::new(None),
            terminal_focused: Cell::new(false),
        });
        let terminal_focus = gtk::EventControllerFocus::new();
        terminal_focus.connect_contains_focus_notify({
            let inner = Rc::downgrade(&inner);
            move |focus| {
                if let Some(inner) = inner.upgrade() {
                    inner.terminal_focused.set(focus.contains_focus());
                }
            }
        });
        terminal.add_controller(terminal_focus);
        window.connect_destroy({
            let inner = Rc::downgrade(&inner);
            move |_| {
                if let Some(inner) = inner.upgrade() {
                    inner.cancel_pending();
                }
            }
        });
        Self { inner }
    }

    pub(crate) fn request(&self) {
        if self.inner.pending_request.get().is_some() {
            return;
        }
        let Some((window, _terminal)) = self.inner.active_target() else {
            self.inner.cancel_pending();
            return;
        };

        let request = self.inner.next_request.get().wrapping_add(1);
        self.inner.next_request.set(request);
        self.inner.pending_request.set(Some(request));
        let cancellable = gio::Cancellable::new();
        self.inner
            .cancellable
            .replace(Some((request, cancellable.clone())));

        let inner = self.inner.clone();
        window
            .clipboard()
            .read_text_async(Some(&cancellable), move |result| {
                inner.clipboard_read(request, result);
            });
    }
}

impl PasteControllerInner {
    fn clipboard_read(
        self: &Rc<Self>,
        request: u64,
        result: Result<Option<glib::GString>, glib::Error>,
    ) {
        if !self.is_current(request) {
            return;
        }
        let Some((window, terminal)) = self.active_target() else {
            self.finish(request);
            return;
        };
        let Ok(Some(text)) = result else {
            self.finish(request);
            return;
        };
        if text.is_empty() {
            self.finish(request);
            return;
        }

        let assessment = classify(text.as_str());
        if !assessment.requires_confirmation() {
            terminal.paste_text(text.as_str());
            self.finish(request);
            return;
        }

        let dialog = gtk::MessageDialog::builder()
            .transient_for(&window)
            .modal(true)
            .destroy_with_parent(true)
            .message_type(gtk::MessageType::Warning)
            .buttons(gtk::ButtonsType::None)
            .text("Confirm risky terminal paste")
            .secondary_text(confirmation_detail(assessment))
            .build();
        dialog.add_button("Cancel", gtk::ResponseType::Cancel);
        dialog.add_button("Paste", gtk::ResponseType::Accept);
        dialog.set_default_response(gtk::ResponseType::Cancel);
        let inner = self.clone();
        dialog.connect_response(move |dialog, response| {
            if !inner.is_current(request) {
                dialog.destroy();
                return;
            }
            let approved = response == gtk::ResponseType::Accept;
            let target_active = dialog.is_active()
                || inner
                    .window
                    .upgrade()
                    .is_some_and(|window| window.is_active());
            let terminal = inner.terminal.upgrade();
            inner.finish(request);
            dialog.destroy();
            if approved && target_active {
                if let Some(terminal) = terminal {
                    terminal.paste_text(text.as_str());
                }
            }
        });
        dialog.present();
    }

    fn is_current(&self, request: u64) -> bool {
        self.pending_request.get() == Some(request)
    }

    fn active_target(&self) -> Option<(gtk::ApplicationWindow, vte::Terminal)> {
        let window = self.window.upgrade()?;
        let terminal = self.terminal.upgrade()?;
        (window.is_active() && self.terminal_focused.get()).then_some((window, terminal))
    }

    fn finish(&self, request: u64) {
        if self.is_current(request) {
            self.pending_request.set(None);
            self.cancellable.borrow_mut().take();
        }
    }

    fn cancel_pending(&self) {
        self.pending_request.set(None);
        if let Some((_, cancellable)) = self.cancellable.borrow_mut().take() {
            cancellable.cancel();
        }
    }
}

fn classify(text: &str) -> PasteAssessment {
    let mut character_count = 0;
    let mut line_break_count = 0;
    let mut disallowed_control_count = 0;
    let mut characters = text.chars().peekable();

    while let Some(character) = characters.next() {
        character_count += 1;
        match character {
            '\r' => {
                line_break_count += 1;
                if characters.peek() == Some(&'\n') {
                    characters.next();
                    character_count += 1;
                }
            }
            '\n' => line_break_count += 1,
            character if is_disallowed_control(character) => disallowed_control_count += 1,
            _ => {}
        }
    }

    let byte_count = text.len();
    PasteAssessment {
        metadata: PasteMetadata {
            line_break_count,
            line_count: if text.is_empty() {
                0
            } else {
                line_break_count + 1
            },
            character_count,
            byte_count,
            disallowed_control_count,
        },
        unusually_large: byte_count > LARGE_PASTE_BYTES,
    }
}

fn is_disallowed_control(character: char) -> bool {
    matches!(character, '\u{0000}'..='\u{001f}' | '\u{007f}'..='\u{009f}')
        && !matches!(character, '\t' | '\n' | '\r')
}

fn confirmation_detail(assessment: PasteAssessment) -> String {
    let metadata = assessment.metadata;
    let mut reasons = Vec::new();
    if metadata.line_break_count > 0 {
        reasons.push(format!(
            "{} line break{}",
            metadata.line_break_count,
            plural(metadata.line_break_count)
        ));
    }
    if metadata.disallowed_control_count > 0 {
        reasons.push(format!(
            "{} disallowed control character{}",
            metadata.disallowed_control_count,
            plural(metadata.disallowed_control_count)
        ));
    }
    if assessment.unusually_large {
        reasons.push(format!(
            "unusually large (over {LARGE_PASTE_BYTES} UTF-8 bytes)"
        ));
    }

    format!(
        "Reasons: {}.\nCounts: {} line{}, {} character{}, {} UTF-8 byte{}.",
        reasons.join(", "),
        metadata.line_count,
        plural(metadata.line_count),
        metadata.character_count,
        plural(metadata.character_count),
        metadata.byte_count,
        plural(metadata.byte_count),
    )
}

fn plural(count: usize) -> &'static str {
    if count == 1 { "" } else { "s" }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normal_tab_and_empty_text_are_safe() {
        for text in ["printf safe", "one\ttwo", ""] {
            assert!(!classify(text).requires_confirmation(), "{text:?}");
        }
        assert_eq!(classify("").metadata.line_count, 0);
    }

    #[test]
    fn lf_cr_and_crlf_have_correct_line_counts() {
        for text in ["one\ntwo", "one\rtwo", "one\r\ntwo"] {
            let metadata = classify(text).metadata;
            assert_eq!(metadata.line_break_count, 1, "{text:?}");
            assert_eq!(metadata.line_count, 2, "{text:?}");
        }

        let metadata = classify("one\r\ntwo\rthree\nfour").metadata;
        assert_eq!(metadata.line_break_count, 3);
        assert_eq!(metadata.line_count, 4);
    }

    #[test]
    fn c0_and_c1_controls_are_risky_but_tab_and_line_breaks_are_separate() {
        let assessment = classify("a\u{0000}\u{001b}\u{007f}\u{0080}\u{009f}\t\r\nb");

        assert!(assessment.requires_confirmation());
        assert_eq!(assessment.metadata.disallowed_control_count, 5);
        assert_eq!(assessment.metadata.line_break_count, 1);
    }

    #[test]
    fn unicode_character_and_utf8_byte_counts_are_distinct() {
        let metadata = classify("é🙂").metadata;

        assert_eq!(metadata.character_count, 2);
        assert_eq!(metadata.byte_count, 6);
        assert_eq!(metadata.line_count, 1);
    }

    #[test]
    fn unusually_large_threshold_is_strictly_greater_than_4096_bytes() {
        let boundary = "a".repeat(LARGE_PASTE_BYTES);
        let over_boundary = "a".repeat(LARGE_PASTE_BYTES + 1);

        assert!(!classify(&boundary).requires_confirmation());
        assert!(classify(&over_boundary).requires_confirmation());
    }

    #[test]
    fn confirmation_metadata_does_not_include_payload_text() {
        let payload = "private-marker\nsecond-private-marker";
        let detail = confirmation_detail(classify(payload));

        assert!(!detail.contains("private-marker"));
        assert!(!detail.contains("second-private-marker"));
        assert!(detail.contains("Reasons: 1 line break"));
        assert!(detail.contains("Counts: 2 lines, 36 characters, 36 UTF-8 bytes"));
    }
}