Files
ghostty-rs/crates/ghostty-core/src/surface/clipboard.rs
T
ddidderr 452b782149 feat(macos): port Ghostty's macOS code paths (Metal, CoreText, Darwin)
Ports the macOS-specific Zig code behind cfg(target_os = "macos"):
- renderer: a GraphicsApi trait with generic::Renderer<G>; Metal backend
  (all renderer/metal/* files, shaders.metal verbatim, runtime library
  compile, IOSurface layer, CVDisplayLink), shadertoy -> MSL via naga;
  OpenGL remains the backend elsewhere.
- font: CoreText face, shaper and discovery with build.rs backend
  selection (coretext default; coretext-freetype / coretext-harfbuzz /
  coretext-noshape features), no fontconfig/freetype/harfbuzz on Apple.
- input: KeymapDarwin via UCKeyTranslate, option-as-alt, mac keycodes.
- os: macos, mach, cf_release_thread, NSProcessInfo args and macOS
  branches; config: macOS defaults, keybinds, Application Support paths.
- termio/core: login(1) wrapping, openpty/execvpe, Darwin child exit
  handling, os_log logging.

Compile-checked for aarch64/x86_64-apple-darwin (clippy -D warnings
clean) using the cross setup in docs/rewrite/notes/macos-M1.md; cannot
run here. The embedded runtime/C API (embedded.zig) is the next step.

Also includes the cross-platform integration edits shared by the crash,
verification, Windows and macOS work (Cargo target-gating, thread
crash-context hooks, apprt-gtk surface/IME refactor, termio/os cfg
gates), a cfg fix so ghostty-config builds on wasm32, and an allow for
the intentional recursion in the crash overflow test.

Test Plan:
- cargo test --workspace (3224 passed)
- cargo clippy --workspace --all-targets -- -D warnings
- cargo check --target aarch64-apple-darwin -p ghostty-renderer (with
  the documented cross env)
2026-10-12 06:02:39 +02:00

397 lines
15 KiB
Rust

//! Clipboard handling for a surface (Zig `Surface.zig`: `clipboardWrite`,
//! `copySelectionToClipboards`, clipboard requests, paste, OSC 52 and
//! desktop notifications).
use std::{
hash::{Hash, Hasher},
rc::Rc,
sync::Arc,
time::{Duration, Instant},
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use ghostty_apprt::{
action::{Action as RtAction, DesktopNotification},
runtime_api::ClipboardError,
structs::{Clipboard, ClipboardRequest},
};
use ghostty_config::types_b::ClipboardAccess;
use ghostty_input::{binding::CopyToClipboard, paste};
use ghostty_terminal::{
formatter::{self, Format, ScreenFormatter},
selection::Selection,
terminal::Terminal,
};
use ghostty_termio::message::Message as IoMessage;
use ghostty_vt::modes::Mode;
use super::{Surface, config::DerivedConfig};
/// `input.paste.Options.fromTerminal`.
#[must_use]
pub fn paste_options_from_terminal(t: &Terminal) -> paste::Options {
paste::Options {
bracketed: t.modes.get(Mode::BracketedPaste),
}
}
impl Surface {
/// Handle an OSC 52 write: `data` is base64 (Zig `clipboardWrite`).
pub(crate) fn clipboard_write(&self, data: &[u8], loc: Clipboard) {
let cfg = self.cfg();
if cfg.clipboard_write == ClipboardAccess::Deny {
log::info!(
"application attempted to write clipboard, but 'clipboard-write' is set to deny"
);
return;
}
// Zig validates the size first (`calcSizeForSlice` on the padded
// standard alphabet rejects lengths that aren't a multiple of 4),
// then decodes ignoring only the `InvalidPadding` error (e.g. nonzero
// trailing bits as in "YR==", which still decodes to "a").
if !data.len().is_multiple_of(4) {
log::info!("application sent invalid base64 data for OSC 52");
return;
}
let lenient = base64::engine::GeneralPurpose::new(
&base64::alphabet::STANDARD,
base64::engine::GeneralPurposeConfig::new()
.with_decode_padding_mode(base64::engine::DecodePaddingMode::RequireCanonical)
.with_decode_allow_trailing_bits(true),
);
let Ok(decoded) = lenient.decode(data) else {
log::info!("application sent invalid base64 data for OSC 52");
return;
};
let text = String::from_utf8_lossy(&decoded).into_owned();
// When clipboard-write is "ask" a prompt is displayed to the user
// asking them to confirm the clipboard access. Each app runtime
// handles this differently.
let confirm = cfg.clipboard_write == ClipboardAccess::Ask;
self.rt_surface
.set_clipboard(loc, &[("text/plain".to_owned(), text)], confirm);
}
/// Format the selection and put it on the given clipboards (Zig
/// `copySelectionToClipboards`). The caller holds the terminal lock.
pub(crate) fn copy_selection_to_clipboards(
&self,
t: &Terminal,
sel: Selection,
clipboards: &[Clipboard],
format: CopyToClipboard,
) {
let cfg = self.cfg();
// The options we'll use for all formatting. We'll just override the
// emit format.
let codepoint_map: Vec<formatter::CodepointMap> =
cfg.clipboard_codepoint_map.map.list.clone();
let opts = formatter::Options {
emit: Format::Plain, // We'll override this below
unwrap: true,
trim: cfg.clipboard_trim_trailing_spaces,
codepoint_map: Some(codepoint_map),
background: t.colors.background.get(),
foreground: t.colors.foreground.get(),
palette: Some(t.colors.palette.current),
};
let run = |opts: formatter::Options| -> Option<String> {
let mut f = ScreenFormatter::new(t.screen(), opts);
f.content = formatter::Content::Selection(Some(sel));
match f.format_string() {
Ok(s) => Some(s),
Err(err) => {
log::error!("error formatting selection for clipboard err={err:?}");
None
}
}
};
let mut contents: Vec<(String, String)> = Vec::new();
match format {
CopyToClipboard::Plain => {
let Some(s) = run(opts) else { return };
contents.push(("text/plain".to_owned(), s));
}
CopyToClipboard::Vt => {
let mut o = opts;
o.emit = Format::Vt;
// Note: We don't apply codepoint mappings to VT format since
// it contains escape sequences that should be preserved
// as-is
let Some(s) = run(o) else { return };
contents.push(("text/plain".to_owned(), s));
}
CopyToClipboard::Html => {
let mut o = opts;
o.emit = Format::Html;
// Note: We don't apply codepoint mappings to HTML format
// since HTML has its own character encoding and entity
// system
let Some(s) = run(o) else { return };
contents.push(("text/html".to_owned(), s));
}
CopyToClipboard::Mixed => {
// First, generate plain text with codepoint mappings applied
let Some(plain) = run(opts.clone()) else {
return;
};
contents.push(("text/plain".to_owned(), plain));
// Second, generate HTML without codepoint mappings
let mut o = opts;
o.emit = Format::Html;
// We purposely don't emit background/foreground for mixed
// mode because the HTML contents is often used for rich text
// input and with trimmed spaces it looks pretty bad.
o.background = None;
o.foreground = None;
let Some(html) = run(o) else { return };
contents.push(("text/html".to_owned(), html));
}
}
for clipboard in clipboards {
self.rt_surface.set_clipboard(*clipboard, &contents, false);
}
}
/// Start a clipboard request, with some basic validation. For example,
/// an OSC 52 request is not actually requested if OSC 52 is disabled.
///
/// Returns true if the request was started, false if it was not (e.g.,
/// clipboard doesn't contain text for paste requests). This allows
/// performable keybinds to pass through when the action cannot be
/// performed.
pub(crate) fn start_clipboard_request(&self, loc: Clipboard, req: ClipboardRequest) -> bool {
match req {
ClipboardRequest::Paste => {} // always allowed
ClipboardRequest::Osc52Read(_) => {
if self.cfg().clipboard_read == ClipboardAccess::Deny {
log::info!(
"application attempted to read clipboard, but 'clipboard-read' is set to deny"
);
return false;
}
}
// No clipboard write code paths travel through this function
ClipboardRequest::Osc52Write(_) => unreachable!(),
}
self.rt_surface.clipboard_request(loc, req)
}
/// The runtime answers a clipboard request (Zig:
/// `completeClipboardRequest`).
pub fn complete_clipboard_request(
&self,
req: ClipboardRequest,
data: &str,
confirmed: bool,
) -> Result<(), ClipboardError> {
match req {
ClipboardRequest::Paste => self.complete_clipboard_paste(data.as_bytes(), confirmed),
ClipboardRequest::Osc52Read(clipboard) => {
self.complete_clipboard_read_osc52(data.as_bytes(), clipboard, confirmed)
}
ClipboardRequest::Osc52Write(clipboard) => {
self.rt_surface.set_clipboard(
clipboard,
&[("text/plain".to_owned(), data.to_owned())],
!confirmed,
);
Ok(())
}
}
}
/// Paste text (also the target of `text_callback`).
pub(crate) fn complete_clipboard_paste(
&self,
data: &[u8],
allow_unsafe: bool,
) -> Result<(), ClipboardError> {
if data.is_empty() {
return Ok(());
}
let cfg = self.cfg();
let encode_opts = {
let mut t = self.lock_terminal();
let opts = paste_options_from_terminal(&t);
// If we have paste protection enabled, we detect unsafe pastes
// and return an error. The error approach allows apprt to
// attempt to complete the paste before falling back to
// requesting confirmation.
//
// We do not do this for bracketed pastes because bracketed
// pastes are by definition safe since they're framed.
let unsafe_paste = 'unsafe_paste: {
// If we've disabled paste protection then we always allow
// the paste.
if !cfg.clipboard_paste_protection {
break 'unsafe_paste false;
}
// If we're allowed to paste unsafe data then we always allow
// the paste. This is set during confirmation usually.
if allow_unsafe {
break 'unsafe_paste false;
}
if opts.bracketed {
// If we're bracketed and the paste contains and ending
// bracket then something naughty might be going on and
// we never trust it.
if data.windows(6).any(|w| w == b"\x1b[201~") {
break 'unsafe_paste true;
}
// If we are bracketed and configured to trust that then
// the paste is not unsafe.
if cfg.clipboard_paste_bracketed_safe {
break 'unsafe_paste false;
}
}
!paste::is_safe(data)
};
if unsafe_paste {
log::info!("potentially unsafe paste detected, rejecting until confirmation");
return Err(ClipboardError::UnsafePaste);
}
// With the lock held, we must scroll to the bottom. We always
// scroll to the bottom for these inputs.
self.scroll_to_bottom(&mut t);
opts
};
// Encode the data. In most cases this doesn't require any copies,
// so we optimize for that case.
let mut data_duped: Vec<u8>;
let vecs = match paste::encode(data, encode_opts) {
Ok(v) => v,
Err(paste::Error::MutableRequired) => {
data_duped = data.to_vec();
paste::encode_mut(&mut data_duped, encode_opts)
}
};
for vec in vecs {
if !vec.is_empty() {
self.queue_io(IoMessage::write_req(vec));
}
}
Ok(())
}
fn complete_clipboard_read_osc52(
&self,
data: &[u8],
clipboard_type: Clipboard,
confirmed: bool,
) -> Result<(), ClipboardError> {
let cfg = self.cfg();
// We should never get here if clipboard-read is set to deny
debug_assert_ne!(cfg.clipboard_read, ClipboardAccess::Deny);
// If clipboard-read is set to ask and we haven't confirmed with the
// user, do that now
if cfg.clipboard_read == ClipboardAccess::Ask && !confirmed {
return Err(ClipboardError::UnauthorizedPaste);
}
// Even if the clipboard data is empty we reply, since presumably the
// client app is expecting a reply.
let kind = match clipboard_type {
Clipboard::Standard => 'c',
Clipboard::Selection => 's',
Clipboard::Primary => 'p',
};
// Wrap our data with the OSC code
let mut buf = format!("\x1b]52;{kind};").into_bytes();
buf.extend_from_slice(STANDARD.encode(data).as_bytes());
buf.extend_from_slice(b"\x1b\\");
self.queue_io(IoMessage::write_req(&buf));
Ok(())
}
/// Set `clipboard-read` / `clipboard-write` to a fixed value for this
/// surface (the runtime calls this after the user chose "remember").
pub(crate) fn set_config_clipboard(&self, read: bool, access: ClipboardAccess) {
let mut config = (*self.cfg().config).clone();
if read {
config.clipboard_read = access;
} else {
config.clipboard_write = access;
}
match DerivedConfig::new(Arc::new(config)) {
Ok(d) => *self.config.borrow_mut() = Rc::new(d),
Err(err) => log::warn!("failed to update clipboard access err={err}"),
}
}
/// Show a desktop notification, rate limited (Zig
/// `showDesktopNotification`).
pub(crate) fn show_desktop_notification(&self, title: &str, body: &str) {
let now = Instant::now();
// Set a limit of one desktop notification per second so that the OS
// doesn't kill us when we run out of resources.
let last = self.app.last_notification_time.get();
if let Some(last) = last
&& now.duration_since(last) < Duration::from_secs(1)
{
log::warn!("rate limiting desktop notifications");
return;
}
let new_digest = {
let mut hash = std::collections::hash_map::DefaultHasher::new();
title.hash(&mut hash);
body.hash(&mut hash);
hash.finish()
};
// Set a limit of one notification per five seconds for desktop
// notifications with identical content.
if let Some(last) = last
&& self.app.last_notification_digest.get() == new_digest
&& now.duration_since(last) < Duration::from_secs(5)
{
log::warn!("suppressing identical desktop notification");
return;
}
self.app.last_notification_time.set(Some(now));
self.app.last_notification_digest.set(new_digest);
self.perform(RtAction::DesktopNotification(DesktopNotification {
title: title.to_owned(),
body: body.to_owned(),
}));
}
/// `textCallback`: text input that is pasted verbatim.
pub fn text_callback(&self, text: &str) -> Result<(), ClipboardError> {
let _crash = self.crash_guard();
self.complete_clipboard_paste(text.as_bytes(), true)
}
}