Skip to main content

rightkit_child_process/
launch.rs

1//! Opens a URL with its registered handler and launches an installed app by its identifier,
2//! through the desktop launcher (`open` on macOS, `rundll32` and `explorer.exe` on Windows,
3//! `xdg-open` on other Unix). The launcher is detached and never killed by the call, the same
4//! as [`reveal`](crate::reveal): it hands the work to the desktop and may stay up.
5//!
6//! Inputs are checked before anything starts. A URL needs a scheme and no whitespace or control
7//! characters, and `file:` is refused (opening a file URL can run a local program). An app
8//! identifier is a macOS bundle identifier (`com.example.App`, `-b`) or a Windows
9//! AppUserModelID (`PackageFamilyName!AppId`, `shell:AppsFolder`).
10
11use crate::{reveal::LAUNCH_WAIT, OwnedCommand};
12use std::{io, process::Stdio};
13
14/// Opens `url` with the handler registered for its scheme (for example `claude://`).
15pub 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
30/// Launches the installed app `id`: a macOS bundle identifier on macOS, a Windows AppUserModelID
31/// on Windows. Other Unix hosts return `Unsupported`.
32pub 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
53/// A scheme (letter first; letters, digits, `+`, `-`, `.`), then `:`, and no whitespace or
54/// control characters anywhere. `file:` is refused.
55fn 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
67/// Reverse-DNS form: at least one dot, letters, digits, `.` and `-` only.
68fn 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
77/// `PackageFamilyName!AppId`: exactly one `!`, and letters, digits, `.`, `-`, `_` only.
78fn 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
85/// Starts the launcher detached and waits up to [`LAUNCH_WAIT`] for it to report. A launcher
86/// that is still running is handing off to the desktop and counts as success.
87fn 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        // `explorer.exe` reports failure with exit code 1 even when it succeeded.
99        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}