Skip to main content

pi/core/platform/
open_browser.rs

1//! Cross-platform "open URL in browser" launcher.
2//!
3//! Ports `.references/pi/packages/coding-agent/src/utils/open-browser.ts`.
4//!
5//! This intentionally never invokes a shell. On Windows, `cmd /c start` is
6//! avoided because `cmd.exe` re-parses metacharacters (`&`, `|`, `^`, ...)
7//! before `start` runs, which would make attacker-controlled URLs injectable.
8//! `rundll32 url.dll,FileProtocolHandler <url>` launches the registered
9//! handler without a re-parse step. The argv is therefore the testable
10//! cross-platform contract.
11
12use std::process::{Command, Stdio};
13use std::sync::OnceLock;
14
15/// Platform discriminator used by [`open_browser_command`] to select the
16/// launcher argv without consulting `std::env::consts` at call time, so unit
17/// tests can exercise every branch on any host.
18#[derive(Copy, Clone, Debug, Eq, PartialEq)]
19pub enum BrowserPlatform {
20    /// macOS: `open <target>`.
21    Darwin,
22    /// Windows: `rundll32 url.dll,FileProtocolHandler <target>`.
23    Windows,
24    /// Linux and other Unix: `xdg-open <target>`.
25    Unix,
26}
27
28impl BrowserPlatform {
29    /// Resolve the current host's platform.
30    #[must_use]
31    pub fn host() -> Self {
32        #[cfg(target_os = "macos")]
33        {
34            Self::Darwin
35        }
36        #[cfg(target_os = "windows")]
37        {
38            Self::Windows
39        }
40        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
41        {
42            Self::Unix
43        }
44    }
45}
46
47/// Resolved launcher argv `(command, args)` for `target` on `platform`.
48///
49/// The argv matches the TypeScript reference exactly so golden snapshots are
50/// stable across host and target. Returned args include the target as the
51/// final element.
52#[must_use]
53pub fn open_browser_command(platform: BrowserPlatform, target: &str) -> (String, Vec<String>) {
54    match platform {
55        BrowserPlatform::Darwin => ("open".to_owned(), vec![target.to_owned()]),
56        BrowserPlatform::Windows => (
57            "rundll32".to_owned(),
58            vec!["url.dll,FileProtocolHandler".to_owned(), target.to_owned()],
59        ),
60        BrowserPlatform::Unix => ("xdg-open".to_owned(), vec![target.to_owned()]),
61    }
62}
63
64/// Capture launcher-spawn errors for tests without panicking in production.
65///
66/// In production this is the no-op [`DefaultSpawnSink`], which silently
67/// swallows the spawn error exactly like the TypeScript `.on("error", () => {})`
68/// handler. Tests install a [`RecordingSpawnSink`] to assert that a missing
69/// launcher is observed rather than crashing the process.
70pub trait SpawnSink: Send + Sync {
71    /// Called when spawning the launcher fails.
72    fn on_error(&self, command: &str, error: std::io::Error);
73}
74
75/// Production sink: silently drop spawn errors.
76#[derive(Debug, Default)]
77pub struct DefaultSpawnSink;
78
79impl SpawnSink for DefaultSpawnSink {
80    fn on_error(&self, _command: &str, _error: std::io::Error) {}
81}
82
83/// Test sink: record the first spawn error.
84#[derive(Debug, Default)]
85pub struct RecordingSpawnSink {
86    /// The first `(command, message)` pair observed, if any.
87    pub error: std::sync::Mutex<Option<(String, String)>>,
88}
89
90impl SpawnSink for RecordingSpawnSink {
91    fn on_error(&self, command: &str, error: std::io::Error) {
92        let mut guard = match self.error.lock() {
93            Ok(guard) => guard,
94            Err(poisoned) => poisoned.into_inner(),
95        };
96        if guard.is_none() {
97            *guard = Some((command.to_owned(), error.to_string()));
98        }
99    }
100}
101
102static SPAWN_SINK: OnceLock<Box<dyn SpawnSink>> = OnceLock::new();
103
104/// Install a spawn-error sink. Subsequent [`open_browser_with`] calls route
105/// spawn failures to it. Intended for tests; production leaves the default
106/// no-op sink in place.
107pub fn install_spawn_sink(sink: Box<dyn SpawnSink>) {
108    let _ = SPAWN_SINK.set(sink);
109}
110
111fn report_error(command: &str, error: std::io::Error) {
112    let sink = SPAWN_SINK.get_or_init(|| Box::<DefaultSpawnSink>::default());
113    sink.on_error(command, error);
114}
115
116/// Open `target` (a URL or file path) in the platform default handler.
117///
118/// Best-effort and detached: the launcher is spawned with inherited-nothing
119/// stdio, then reaped asynchronously. Launch failures are reported to the
120/// installed [`SpawnSink`] and never panic, matching the TypeScript
121/// fire-and-forget contract — callers still present the target to the user.
122pub fn open_browser(target: &str) {
123    open_browser_with(BrowserPlatform::host(), target);
124}
125
126/// Open `target` using an explicit platform selector.
127///
128/// Split from [`open_browser`] so the argv branch is unit-testable on any
129/// host without depending on `cfg(target_os)`.
130pub fn open_browser_with(platform: BrowserPlatform, target: &str) {
131    let (command, args) = open_browser_command(platform, target);
132    let spawn_result = Command::new(&command)
133        .args(&args)
134        .stdin(Stdio::null())
135        .stdout(Stdio::null())
136        .stderr(Stdio::null())
137        .spawn();
138    match spawn_result {
139        Ok(mut child) => {
140            // Detach: the TypeScript reference calls `.unref()`. We do not
141            // await the child; a best-effort non-blocking poll releases the
142            // handle so the launcher runs independently.
143            child.try_wait().ok();
144        }
145        Err(error) => report_error(&command, error),
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn darwin_argv_is_open_target() {
155        let (cmd, args) = open_browser_command(BrowserPlatform::Darwin, "https://pi.dev");
156        assert_eq!(cmd, "open");
157        assert_eq!(args, vec!["https://pi.dev"]);
158    }
159
160    #[test]
161    fn windows_argv_is_rundll32_fileprotocohandler_target() {
162        let (cmd, args) =
163            open_browser_command(BrowserPlatform::Windows, "https://pi.dev/session/#abc");
164        assert_eq!(cmd, "rundll32");
165        assert_eq!(
166            args,
167            vec!["url.dll,FileProtocolHandler", "https://pi.dev/session/#abc"]
168        );
169    }
170
171    #[test]
172    fn unix_argv_is_xdg_open_target() {
173        let (cmd, args) = open_browser_command(BrowserPlatform::Unix, "https://pi.dev");
174        assert_eq!(cmd, "xdg-open");
175        assert_eq!(args, vec!["https://pi.dev"]);
176    }
177
178    #[test]
179    fn host_platform_matches_cfg() {
180        #[cfg(target_os = "macos")]
181        assert_eq!(BrowserPlatform::host(), BrowserPlatform::Darwin);
182        #[cfg(target_os = "windows")]
183        assert_eq!(BrowserPlatform::host(), BrowserPlatform::Windows);
184        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
185        assert_eq!(BrowserPlatform::host(), BrowserPlatform::Unix);
186    }
187}