Escaped config strings (ghostty_os::osbytes) kept reaching OS boundaries undecoded, and decoding was occasionally applied to plain text. The root cause was that both were plain `String`s. Now: - `EncodedOs` is a newtype (no Display/AsRef<Path>) that decodes only via to_os/to_path/to_bytes/to_wide; plain text enters via from_plain_text. OS helpers (env, home, xdg, passwd, resources dir, path expansion), config::Path, WorkingDirectory, theme/file_load/edit paths, terminal pwd, OpenUrl/Pwd actions and termio cwd use it, so the compiler forces decoding at every boundary. os::open takes &OsStr. Config output is unchanged. Fixes all rows of docs/rewrite/notes/review-rv5-codex.md (final-Z7.md): screen-file copy; regex/OSC 8 URLs no longer decoded (regression); lossless Windows WTF-8/WTF-16 for args, env and CreateProcessW; Windows resources dir/TERMINFO; color OSC request lists and X11 color lookup no longer allocate globally (C zero-allocation test extended); macOS Mach handler resumes all threads before KERN_FAILURE (hang regression) and SIGABRT minidumps go through the exception thread; plus open-config, background images, crash dir, ssh cache, exe dir/PATH, fontconfig filenames, file links, kitty TMPDIR, .hushlogin and benchmark paths. The typing also surfaced and fixed gtk-custom-css, bell audio, bash HISTFILE, OSC 52 writes and the C config getters. Test Plan: - cargo test --workspace (3376 passed) - just check-macos (both), just check-windows, just check-wasm - diff <(ghostty-rs +show-config --default --docs) \ <(ghostty +show-config --default --docs)
171 lines
6.0 KiB
Rust
171 lines
6.0 KiB
Rust
use std::{
|
|
ffi::OsStr,
|
|
io,
|
|
process::{Command, Stdio},
|
|
};
|
|
|
|
/// Kind of URL being opened (`apprt.action.OpenUrl.Kind`). On Linux the
|
|
/// kind is ignored by [`open`] (it only matters for macOS `open -t`).
|
|
// TODO(integration): replace with the apprt action's OpenUrl kind once
|
|
// ghostty-core defines it.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum OpenUrlKind {
|
|
Text,
|
|
Html,
|
|
#[default]
|
|
Unknown,
|
|
}
|
|
|
|
/// Open a URL in the default handling application (via `xdg-open`).
|
|
///
|
|
/// `url` is the exact OS string to hand to the opener. Callers holding an
|
|
/// [`EncodedOs`](crate::osbytes::EncodedOs) decode it explicitly
|
|
/// (`to_os`); plain text (a regex link, an OSC 8 URI) is wrapped with
|
|
/// `EncodedOs::from_plain_text` first, so it is never decoded by mistake.
|
|
///
|
|
/// Any output on stderr is logged as a warning in the application logs.
|
|
/// Output on stdout is ignored.
|
|
///
|
|
/// This function is purposely simple for the sake of providing
|
|
/// some portable way to open URLs. If you are implementing an
|
|
/// apprt for Ghostty, you should consider doing something special-cased
|
|
/// for your platform.
|
|
pub fn open(kind: OpenUrlKind, url: &OsStr) -> io::Result<()> {
|
|
let mut cmd = open_command(kind, url)?;
|
|
|
|
// Pipe stdout/stderr so we can collect output from the command.
|
|
cmd.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
// In the snap on Linux the launcher exports LD_LIBRARY_PATH pointing at
|
|
// the snap's bundled libraries. Leaking this into child process can
|
|
// can be problematic, so let's drop it from the env.
|
|
// TODO(integration): Zig does this only for `build_config.snap` builds;
|
|
// we do it when running inside a snap (`SNAP` is set).
|
|
if std::env::var_os("SNAP").is_some() {
|
|
cmd.env_remove("LD_LIBRARY_PATH");
|
|
}
|
|
|
|
// Spawn the process on our same thread so we can detect failure
|
|
// quickly.
|
|
let child = cmd.spawn()?;
|
|
|
|
// Create a thread that handles collecting output and reaping
|
|
// the process. This is done in a separate thread because SOME
|
|
// open implementations block and some do not. It's easier to just
|
|
// spawn a thread to handle this so that we never block.
|
|
std::thread::Builder::new()
|
|
.name("open-url".into())
|
|
.spawn(move || open_thread(child))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Build the platform's "open URL" command (Zig `open`'s switch over
|
|
/// `builtin.os.tag`).
|
|
#[cfg(windows)]
|
|
#[allow(clippy::unnecessary_wraps)]
|
|
fn open_command(kind: OpenUrlKind, url: &OsStr) -> io::Result<Command> {
|
|
let _ = kind;
|
|
let mut cmd = Command::new("rundll32");
|
|
cmd.arg("url.dll,FileProtocolHandler").arg(url);
|
|
Ok(cmd)
|
|
}
|
|
|
|
/// Linux and the BSDs use `xdg-open`.
|
|
#[cfg(not(any(windows, target_os = "macos", target_os = "ios")))]
|
|
#[allow(clippy::unnecessary_wraps)]
|
|
fn open_command(kind: OpenUrlKind, url: &OsStr) -> io::Result<Command> {
|
|
let _ = kind;
|
|
let mut cmd = Command::new("xdg-open");
|
|
cmd.arg(url);
|
|
Ok(cmd)
|
|
}
|
|
|
|
/// macOS uses `open` (`open -t` for text files).
|
|
#[cfg(target_os = "macos")]
|
|
#[allow(clippy::unnecessary_wraps)]
|
|
fn open_command(kind: OpenUrlKind, url: &OsStr) -> io::Result<Command> {
|
|
let mut cmd = Command::new("open");
|
|
cmd.args(macos_open_args(kind, url));
|
|
Ok(cmd)
|
|
}
|
|
|
|
/// Arguments of the macOS `open` command for a URL kind.
|
|
#[cfg_attr(not(any(target_os = "macos", all(test, unix))), allow(dead_code))]
|
|
fn macos_open_args(kind: OpenUrlKind, url: &OsStr) -> Vec<&OsStr> {
|
|
match kind {
|
|
OpenUrlKind::Text => vec![OsStr::new("-t"), url],
|
|
OpenUrlKind::Html | OpenUrlKind::Unknown => vec![url],
|
|
}
|
|
}
|
|
|
|
/// iOS cannot spawn processes (`error.Unimplemented`).
|
|
#[cfg(target_os = "ios")]
|
|
#[allow(clippy::unnecessary_wraps)]
|
|
fn open_command(_kind: OpenUrlKind, _url: &OsStr) -> io::Result<Command> {
|
|
Err(io::Error::new(io::ErrorKind::Unsupported, "unimplemented"))
|
|
}
|
|
|
|
fn open_thread(child: std::process::Child) {
|
|
// 50 KiB is the default value used by std.process.Child.run and should
|
|
// be enough to get the output we care about.
|
|
const OUTPUT_MAX_SIZE: usize = 50 * 1024;
|
|
|
|
let output = match child.wait_with_output() {
|
|
Ok(o) => o,
|
|
Err(e) => {
|
|
log::warn!("error waiting for open command err={e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
// If we have any stderr output we log it. This makes it easier for
|
|
// users to debug why some open commands may not work as expected.
|
|
let stderr = &output.stderr[..output.stderr.len().min(OUTPUT_MAX_SIZE)];
|
|
if !stderr.is_empty() {
|
|
log::warn!("wait stderr={}", String::from_utf8_lossy(stderr));
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, unix))]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn macos_open_command_args() {
|
|
let a = OsStr::new("/a");
|
|
let u = OsStr::new("u");
|
|
assert_eq!(macos_open_args(OpenUrlKind::Text, a), ["-t", "/a"]);
|
|
assert_eq!(macos_open_args(OpenUrlKind::Html, u), ["u"]);
|
|
assert_eq!(macos_open_args(OpenUrlKind::Unknown, u), ["u"]);
|
|
}
|
|
|
|
/// The opener receives decoded bytes for encoded paths and the text
|
|
/// unchanged for plain URLs (even when they contain the escape char).
|
|
#[test]
|
|
fn encoded_paths_decode_but_plain_urls_do_not() {
|
|
use std::os::unix::ffi::OsStrExt as _;
|
|
|
|
use crate::osbytes::EncodedOs;
|
|
let path = EncodedOs::from_bytes(b"/tmp/a\xffb");
|
|
assert_eq!(path.to_os().as_bytes(), b"/tmp/a\xffb");
|
|
|
|
// U+FDD0 U+00FF in an ordinary URL must stay two chars, not byte FF.
|
|
let url = "https://x/\u{fdd0}\u{ff}";
|
|
let plain = EncodedOs::from_plain_text(url);
|
|
assert_eq!(plain.to_os().as_bytes(), url.as_bytes());
|
|
}
|
|
|
|
#[cfg(not(target_vendor = "apple"))]
|
|
#[test]
|
|
fn open_reports_spawn_failure_quickly() {
|
|
// With an empty PATH, xdg-open can't be found; Zig detects this
|
|
// synchronously and so do we.
|
|
temp_env::with_var("PATH", Some("/nonexistent-dir"), || {
|
|
let err = open(OpenUrlKind::Unknown, OsStr::new("https://example.com")).unwrap_err();
|
|
assert_eq!(err.kind(), io::ErrorKind::NotFound);
|
|
});
|
|
}
|
|
}
|