rightkit_child_process/
launch.rs1use crate::{reveal::LAUNCH_WAIT, OwnedCommand};
12use std::{io, process::Stdio};
13
14pub fn open_url(url: &str) -> io::Result<()> {
16 if !valid_url(url) {
17 return Err(invalid(format!(
18 "refusing to open {url:?}: not an allowed URL"
19 )));
20 }
21 if cfg!(target_os = "macos") {
22 hand_off("open", &[url])
23 } else if cfg!(windows) {
24 hand_off("rundll32.exe", &["url.dll,FileProtocolHandler", url])
25 } else {
26 hand_off("xdg-open", &[url])
27 }
28}
29
30pub fn launch_app(id: &str) -> io::Result<()> {
33 if cfg!(target_os = "macos") {
34 if !valid_bundle_id(id) {
35 return Err(invalid(format!("{id:?} is not a bundle identifier")));
36 }
37 hand_off("open", &["-b", id])
38 } else if cfg!(windows) {
39 if !valid_app_user_model_id(id) {
40 return Err(invalid(format!("{id:?} is not an AppUserModelID")));
41 }
42 let target = format!("shell:AppsFolder\\{id}");
43 hand_off("explorer.exe", &[target.as_str()])
44 } else {
45 Err(io::Error::from(io::ErrorKind::Unsupported))
46 }
47}
48
49fn invalid(message: String) -> io::Error {
50 io::Error::new(io::ErrorKind::InvalidInput, message)
51}
52
53fn valid_url(url: &str) -> bool {
56 let Some((scheme, _rest)) = url.split_once(':') else {
57 return false;
58 };
59 let mut chars = scheme.chars();
60 let scheme_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
61 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
62 scheme_ok
63 && !scheme.eq_ignore_ascii_case("file")
64 && !url.chars().any(|c| c.is_whitespace() || c.is_control())
65}
66
67fn valid_bundle_id(id: &str) -> bool {
69 id.contains('.')
70 && !id.starts_with('.')
71 && !id.ends_with('.')
72 && id
73 .chars()
74 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
75}
76
77fn valid_app_user_model_id(id: &str) -> bool {
79 id.matches('!').count() == 1
80 && id
81 .chars()
82 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '!'))
83}
84
85fn hand_off(program: &str, args: &[&str]) -> io::Result<()> {
88 let mut command = OwnedCommand::new(program);
89 command
90 .command_mut()
91 .args(args)
92 .stdin(Stdio::null())
93 .stdout(Stdio::null())
94 .stderr(Stdio::null());
95 command.windows_hide();
96 let mut child = command.spawn_uncontained_detached()?;
97 match child.wait_timeout(LAUNCH_WAIT)? {
98 Some(status) if !status.success() && !cfg!(windows) => Err(io::Error::other(format!(
100 "the launcher exited with {status}"
101 ))),
102 _ => Ok(()),
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 #[test]
111 fn urls_need_a_scheme_and_no_whitespace() {
112 assert!(valid_url("claude://chat/abc"));
113 assert!(valid_url("https://example.com/a?b=c"));
114 assert!(!valid_url("example.com"));
115 assert!(!valid_url("file:///etc/passwd"));
116 assert!(!valid_url("FILE:/x"));
117 assert!(!valid_url("https://example.com/a b"));
118 assert!(!valid_url("https://example.com/\nx"));
119 assert!(!valid_url("1abc://x"));
120 }
121
122 #[test]
123 fn bundle_ids_and_app_user_model_ids_are_checked_per_shape() {
124 assert!(valid_bundle_id("com.apple.Safari"));
125 assert!(!valid_bundle_id("Safari"));
126 assert!(!valid_bundle_id("com.example/../x"));
127 assert!(valid_app_user_model_id(
128 "Microsoft.WindowsCalculator_8wekyb3d8bbwe!App"
129 ));
130 assert!(!valid_app_user_model_id("NoBang"));
131 assert!(!valid_app_user_model_id("A!B!C"));
132 assert!(!valid_app_user_model_id("A!B C"));
133 }
134
135 #[test]
136 fn a_refused_url_starts_nothing() {
137 let error = open_url("file:///tmp/x").unwrap_err();
138 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
139 }
140}