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 { 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 { 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 { 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 { 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); }); } }