Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions crates/ember-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,11 @@ pub(crate) struct Shared {
pub(crate) control_server: Option<control::ControlServer>,
/// 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<std::sync::atomic::AtomicBool>,
/// Backdrop animation clock.
pub(crate) backdrop_since: Instant,
/// Native menu bar (macOS); inert elsewhere. Kept alive for the app's life.
Expand Down Expand Up @@ -936,6 +941,9 @@ impl ApplicationHandler<EmberEvent> 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(),
Expand Down
6 changes: 5 additions & 1 deletion crates/ember-app/src/window_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 15 additions & 1 deletion crates/ember-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
33 changes: 33 additions & 0 deletions crates/ember-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------------------------------------------------------

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
77 changes: 64 additions & 13 deletions crates/ember-session/src/local_pty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AtomicBool>,
}

impl LocalPtyConfig {
Expand All @@ -52,7 +54,7 @@ impl LocalPtyConfig {
args: Vec::new(),
cwd: None,
shell_integration: true,
osc52_read: false,
osc52_read: Arc::new(AtomicBool::new(false)),
}
}
}
Expand Down Expand Up @@ -224,8 +226,11 @@ enum Ev {
struct EmberListener {
events: Sender<BackendEvent>,
outbox: Arc<Mutex<Vec<u8>>>,
/// See [`LocalPtyConfig::osc52_read`].
osc52_read: bool,
/// See [`LocalPtyConfig::osc52_read`]. Checked per request, never cached.
osc52_read: Arc<AtomicBool>,
/// 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,
Expand Down Expand Up @@ -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()
};
Expand Down Expand Up @@ -298,6 +300,14 @@ fn reader_loop(mut reader: Box<dyn Read + Send>, itx: SyncSender<Ev>) {
}
}

/// 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,
Expand All @@ -308,7 +318,7 @@ fn emulation_loop(
master: Box<dyn portable_pty::MasterPty + Send>,
mut child: Box<dyn portable_pty::Child + Send + Sync>,
busy: Arc<AtomicBool>,
osc52_read: bool,
osc52_read: Arc<AtomicBool>,
) {
// 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
Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand All @@ -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]
Expand Down
Loading