diff --git a/CHANGELOG.md b/CHANGELOG.md index 7816528..3b740eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ follow [Semantic Versioning](https://semver.org). ## [Unreleased] +### Added + +- A Settings toggle for clipboard reads: "Programs can read clipboard", in the + Terminal section. It is the same `osc52_read` switch that config.toml + offers, now one keypress away, for when you want nvim or tmux over + SSH to paste from your local clipboard. Any program in the terminal can read + what you copied while it is on, so it stays off by default. Changes apply + at once, including in panes that are already open. + ### Fixed - OSC 52 clipboard reads now work as documented. Since 0.5.0, a program diff --git a/crates/ember-app/src/main.rs b/crates/ember-app/src/main.rs index ff811aa..0d84595 100644 --- a/crates/ember-app/src/main.rs +++ b/crates/ember-app/src/main.rs @@ -344,6 +344,11 @@ pub(crate) struct Shared { pub(crate) control_server: Option, /// User config (the Settings overlay reads + mutates it). pub(crate) config: Config, + /// The live OSC 52 read gate every pane's session shares (see + /// `LocalPtyConfig::osc52_read`). Mirrors `config.osc52_read`: seeded from + /// it here and updated whenever a setting changes, so turning reads off + /// reaches panes that are already open. + pub(crate) osc52_read_gate: std::sync::Arc, /// Backdrop animation clock. pub(crate) backdrop_since: Instant, /// Native menu bar (macOS); inert elsewhere. Kept alive for the app's life. @@ -936,6 +941,9 @@ impl ApplicationHandler for App { next_tab: 2, control_rx: self.control_rx.take(), control_server: self.control_server.take(), + osc52_read_gate: std::sync::Arc::new(std::sync::atomic::AtomicBool::new( + config.osc52_read, + )), config, backdrop_since: Instant::now(), menu: ember_platform::build_menu(), diff --git a/crates/ember-app/src/window_state.rs b/crates/ember-app/src/window_state.rs index 64f624e..e0fe509 100644 --- a/crates/ember-app/src/window_state.rs +++ b/crates/ember-app/src/window_state.rs @@ -1018,7 +1018,7 @@ impl WindowState { ) -> bool { let mut cfg = LocalPtyConfig::new(id.clone(), dims); cfg.shell_integration = shared.config.shell_integration; - cfg.osc52_read = shared.config.osc52_read; + cfg.osc52_read = std::sync::Arc::clone(&shared.osc52_read_gate); cfg.cwd = cwd.map(std::path::PathBuf::from); let handle = match LocalPty::spawn(cfg) { Ok(h) => h, @@ -4756,6 +4756,10 @@ impl WindowState { } self.apply_appearance(shared); shared.set_developer_mode(shared.config.developer_mode); + shared.osc52_read_gate.store( + shared.config.osc52_read, + std::sync::atomic::Ordering::Relaxed, + ); let mut relayout = self.renderer.set_font_size(shared.config.font.size); relayout |= self.renderer.set_family(shared.config.font.family.clone()); if relayout { diff --git a/crates/ember-core/src/config.rs b/crates/ember-core/src/config.rs index 41f792b..f0b050d 100644 --- a/crates/ember-core/src/config.rs +++ b/crates/ember-core/src/config.rs @@ -39,7 +39,8 @@ pub struct Config { /// the clipboard's CONTENTS. A data-exfiltration surface (any program in /// the terminal can quietly read what you copied), so OFF by default - /// the request is answered with an empty payload. Copy direction (OSC 52 - /// write) is always on. config.toml only. + /// write) is always on. Also the Settings toggle "Programs can read + /// clipboard" (Terminal section), which applies to open panes at once. pub osc52_read: bool, /// Which of the wisp's visual styles to draw — see [`WispStyleSelection`]. /// Orthogonal to `wisp` (the on/off switch): this only matters while @@ -367,6 +368,19 @@ mod tests { assert_eq!(c.background.image_fit, "cover"); } + #[test] + fn osc52_read_survives_a_save_and_load() { + let c = Config { + osc52_read: true, + ..Config::default() + }; + let back: Config = toml::from_str(&toml::to_string_pretty(&c).unwrap()).unwrap(); + assert!(back.osc52_read); + // An older config.toml without the key still loads with reads off. + let old: Config = toml::from_str("").unwrap(); + assert!(!old.osc52_read); + } + #[test] fn roundtrips_through_toml() { let c = Config::default(); diff --git a/crates/ember-core/src/settings.rs b/crates/ember-core/src/settings.rs index b87b9c1..1829500 100644 --- a/crates/ember-core/src/settings.rs +++ b/crates/ember-core/src/settings.rs @@ -301,6 +301,12 @@ fn fmt_option_as_meta(c: &Config) -> String { fn adjust_option_as_meta(c: &mut Config, _dir: f32) { c.option_as_meta = !c.option_as_meta; } +fn fmt_osc52_read(c: &Config) -> String { + on_off(c.osc52_read) +} +fn adjust_osc52_read(c: &mut Config, _dir: f32) { + c.osc52_read = !c.osc52_read; +} // --- Session restore --------------------------------------------------------- @@ -469,6 +475,18 @@ pub fn setting_rows() -> &'static [SettingRow] { characters. Takes effect immediately.", ), }, + SettingRow { + label: "Programs can read clipboard", + kind: RowKind::Toggle, + format: fmt_osc52_read, + adjust: Some(adjust_osc52_read), + help: Help::Inline( + "Lets programs ask for your clipboard's contents (OSC 52), so nvim or tmux over \ + SSH can paste from your local clipboard. Any program running in the terminal \ + can then read what you copied, so leave it off unless you need it. Off answers \ + with nothing. Takes effect immediately, in open panes too.", + ), + }, SettingRow { label: "Session", kind: RowKind::SectionHeader, @@ -798,6 +816,21 @@ mod tests { assert_eq!(c.shell_integration, before.shell_integration); } + #[test] + fn clipboard_read_toggle_is_off_by_default_and_mutates_only_osc52_read() { + let mut c = Config::default(); + assert!(!c.osc52_read, "reading the clipboard must stay opt-in"); + assert_eq!(fmt_osc52_read(&c), on_off(false)); + let before = c.clone(); + (row("Programs can read clipboard").adjust.unwrap())(&mut c, 1.0); + assert!(c.osc52_read); + assert_eq!(fmt_osc52_read(&c), on_off(true)); + assert_eq!(c.option_as_meta, before.option_as_meta); + assert_eq!(c.shell_integration, before.shell_integration); + (row("Programs can read clipboard").adjust.unwrap())(&mut c, 1.0); + assert!(!c.osc52_read, "the toggle turns it back off"); + } + #[test] fn developer_mode_toggle_mutates_only_developer_mode() { let mut c = Config::default(); diff --git a/crates/ember-session/src/local_pty.rs b/crates/ember-session/src/local_pty.rs index 4fc162f..7fae6fc 100644 --- a/crates/ember-session/src/local_pty.rs +++ b/crates/ember-session/src/local_pty.rs @@ -39,8 +39,10 @@ pub struct LocalPtyConfig { pub shell_integration: bool, /// Answer OSC 52 clipboard READ requests with real clipboard contents. /// Off = reply with an empty payload (the safe default; see the config - /// knob's doc in ember-core). - pub osc52_read: bool, + /// knob's doc in ember-core). A shared, live gate: the app holds one and + /// hands every pane a clone, and each read checks it at that moment, so + /// turning the setting off takes effect in panes that are already open. + pub osc52_read: Arc, } impl LocalPtyConfig { @@ -52,7 +54,7 @@ impl LocalPtyConfig { args: Vec::new(), cwd: None, shell_integration: true, - osc52_read: false, + osc52_read: Arc::new(AtomicBool::new(false)), } } } @@ -224,8 +226,11 @@ enum Ev { struct EmberListener { events: Sender, outbox: Arc>>, - /// See [`LocalPtyConfig::osc52_read`]. - osc52_read: bool, + /// See [`LocalPtyConfig::osc52_read`]. Checked per request, never cached. + osc52_read: Arc, + /// Where a permitted read gets the clipboard's text. The system clipboard + /// in production; a fixed value in tests. + read_clipboard: fn() -> String, /// For answering OSC 10/11 color queries (nvim's background detection /// blocks on this at startup). Static defaults — good enough for queries. palette: crate::palette::Palette, @@ -256,11 +261,8 @@ impl EventListener for EmberListener { // data-exfiltration surface, so gated (default off = empty reply, // which unblocks well-behaved clients instead of hanging them). AlacEvent::ClipboardLoad(_, format) => { - let text = if self.osc52_read { - arboard::Clipboard::new() - .ok() - .and_then(|mut c| c.get_text().ok()) - .unwrap_or_default() + let text = if self.osc52_read.load(Ordering::Relaxed) { + (self.read_clipboard)() } else { String::new() }; @@ -298,6 +300,14 @@ fn reader_loop(mut reader: Box, itx: SyncSender) { } } +/// The system clipboard's text, or empty if it can't be read. +fn system_clipboard_text() -> String { + arboard::Clipboard::new() + .ok() + .and_then(|mut c| c.get_text().ok()) + .unwrap_or_default() +} + #[allow(clippy::too_many_arguments)] fn emulation_loop( dims: GridDims, @@ -308,7 +318,7 @@ fn emulation_loop( master: Box, mut child: Box, busy: Arc, - osc52_read: bool, + osc52_read: Arc, ) { // The shell is its own process-group leader; when a foreground command runs, // the PTY's foreground pgrp differs from the shell pid. Recompute after each @@ -324,6 +334,7 @@ fn emulation_loop( events: event_tx.clone(), outbox: Arc::clone(&outbox), osc52_read, + read_clipboard: system_clipboard_text, palette: crate::palette::Palette::dark(), }; let mut proj = AlacrittyProjection::new(dims, listener); @@ -508,7 +519,8 @@ mod tests { let l = EmberListener { events: tx, outbox: Arc::clone(&outbox), - osc52_read: false, + osc52_read: Arc::new(AtomicBool::new(false)), + read_clipboard: || panic!("the clipboard must not be read while reads are off"), palette: crate::palette::Palette::dark(), }; l.send_event(AlacEvent::ClipboardLoad( @@ -533,7 +545,8 @@ mod tests { let listener = EmberListener { events: tx, outbox: Arc::clone(&outbox), - osc52_read: false, + osc52_read: Arc::new(AtomicBool::new(false)), + read_clipboard: || panic!("the clipboard must not be read while reads are off"), palette: crate::palette::Palette::dark(), }; let mut proj = AlacrittyProjection::new(GridDims::new(80, 24), listener); @@ -545,6 +558,44 @@ mod tests { ); } + /// The setting is live: flipping the shared gate changes the answer for a + /// pane that's already running, in both directions, and nothing is read + /// from the clipboard while it's off. + #[test] + fn osc52_read_gate_is_live_in_a_running_pane() { + let (tx, _rx) = mpsc::channel(); + let outbox = Arc::new(Mutex::new(Vec::new())); + let gate = Arc::new(AtomicBool::new(false)); + let listener = EmberListener { + events: tx, + outbox: Arc::clone(&outbox), + osc52_read: Arc::clone(&gate), + read_clipboard: || "secret".to_string(), + palette: crate::palette::Palette::dark(), + }; + let mut proj = AlacrittyProjection::new(GridDims::new(80, 24), listener); + let mut read = || { + outbox.lock().unwrap().clear(); + proj.advance(b"\x1b]52;c;?\x07"); + String::from_utf8(outbox.lock().unwrap().clone()).unwrap() + }; + + assert_eq!(read(), "\x1b]52;c;\x07", "off: an empty reply"); + gate.store(true, Ordering::Relaxed); + // "secret" in base64, as the engine formats it. + assert_eq!( + read(), + "\x1b]52;c;c2VjcmV0\x07", + "on: the clipboard's contents" + ); + gate.store(false, Ordering::Relaxed); + assert_eq!( + read(), + "\x1b]52;c;\x07", + "off again: empty, with no restart" + ); + } + /// Reconstruct row 0's text from the frame lane until `needle` appears or we /// time out. Proves the full path: shell → PTY → engine → projection → lane. #[test]