use std::process::{Command, Stdio};
pub fn open_url(raw: &str) -> Result<(), String> {
let url = normalize_url(raw).ok_or_else(|| "empty or unsupported link".to_string())?;
let mut cmd = platform_command(&url);
let mut child = cmd
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("could not open {url}: {e}"))?;
std::thread::Builder::new()
.name("mach-open-reaper".into())
.spawn(move || {
let _ = child.wait();
})
.map(|_| ())
.map_err(|e| format!("could not monitor link opener: {e}"))
}
pub fn normalize_url(raw: &str) -> Option<String> {
let url = raw.trim();
if url.is_empty() {
return None;
}
if url.chars().any(char::is_control) {
return None;
}
if has_supported_scheme(url) {
return Some(url.to_string());
}
if has_explicit_scheme(url) {
return None;
}
Some(format!("https://{url}"))
}
pub(crate) fn has_supported_scheme(url: &str) -> bool {
let lower = url.to_ascii_lowercase();
lower.starts_with("http://")
|| lower.starts_with("https://")
|| lower.starts_with("mailto:")
|| lower.starts_with("file:")
}
fn has_explicit_scheme(url: &str) -> bool {
let Some(colon) = url.find(':') else {
return false;
};
let scheme = &url[..colon];
if !scheme.starts_with(|c: char| c.is_ascii_alphabetic())
|| !scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
{
return false;
}
let port = url[colon + 1..]
.split(['/', '?', '#'])
.next()
.unwrap_or_default();
port.is_empty() || !port.chars().all(|c| c.is_ascii_digit())
}
fn platform_command(url: &str) -> Command {
if cfg!(target_os = "macos") {
let mut c = Command::new("open");
c.args(["--", url]);
c
} else if cfg!(target_os = "windows") {
let mut c = Command::new("rundll32");
c.args(["url.dll,FileProtocolHandler", url]);
c
} else {
let mut c = Command::new("xdg-open");
c.arg(url);
c
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_an_existing_scheme() {
assert_eq!(
normalize_url("https://x.ai/foo"),
Some("https://x.ai/foo".into())
);
assert_eq!(normalize_url("mailto:a@b.c"), Some("mailto:a@b.c".into()));
assert_eq!(
normalize_url("file:///tmp/task.txt"),
Some("file:///tmp/task.txt".into())
);
}
#[test]
fn adds_https_to_a_bare_host() {
assert_eq!(
normalize_url("example.com/path"),
Some("https://example.com/path".into())
);
}
#[test]
fn rejects_blank() {
assert_eq!(normalize_url(" "), None);
}
#[test]
fn rejects_terminal_controls_and_unapproved_handlers() {
assert_eq!(normalize_url("https://example.com/\u{1b}[31m"), None);
assert_eq!(normalize_url("javascript://alert"), None);
assert_eq!(normalize_url("javascript:alert(1)"), None);
assert_eq!(normalize_url("data:text/html,hello"), None);
assert_eq!(normalize_url("ssh://example.com"), None);
}
#[test]
fn treats_a_bare_host_with_a_port_as_https() {
assert_eq!(
normalize_url("localhost:3000/tasks"),
Some("https://localhost:3000/tasks".into())
);
}
}