1use crate::error::{BrowserError, Result};
2use crate::policy::{AdmissionHook, EventSink, NetworkPolicy};
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6#[derive(Clone, Debug, Eq, PartialEq)]
8pub enum ProfileSpec {
9 Temporary,
11 Named { root: PathBuf, name: String },
14}
15
16#[derive(Clone)]
17pub struct LaunchOptions {
18 pub headless: bool,
19 pub mute_audio: bool,
21 pub profile: ProfileSpec,
22 pub chrome_path: Option<PathBuf>,
25 pub allow_system_chrome: bool,
29 pub viewport: (u32, u32),
30 pub launch_timeout: Duration,
31 pub extra_args: Vec<String>,
33 pub download_dir: Option<PathBuf>,
36 pub upload_root: Option<PathBuf>,
38 pub admission: Option<AdmissionHook>,
41 pub network: NetworkPolicy,
44 pub on_event: Option<EventSink>,
46 #[cfg(windows)]
49 pub windows_job: Option<std::sync::Arc<std::os::windows::io::OwnedHandle>>,
50}
51
52impl std::fmt::Debug for LaunchOptions {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 f.debug_struct("LaunchOptions")
55 .field("headless", &self.headless)
56 .field("profile", &self.profile)
57 .field("network", &self.network)
58 .field("admission", &self.admission.is_some())
59 .finish_non_exhaustive()
60 }
61}
62
63impl Default for LaunchOptions {
64 fn default() -> Self {
65 Self {
66 headless: true,
67 mute_audio: true,
68 profile: ProfileSpec::Temporary,
69 chrome_path: None,
70 allow_system_chrome: false,
71 viewport: (1280, 800),
72 launch_timeout: Duration::from_secs(60),
73 extra_args: Vec::new(),
74 download_dir: None,
75 upload_root: None,
76 admission: None,
77 network: NetworkPolicy::default(),
78 on_event: None,
79 #[cfg(windows)]
80 windows_job: None,
81 }
82 }
83}
84
85impl LaunchOptions {
86 pub fn system_chrome(mut self) -> Self {
88 self.allow_system_chrome = true;
89 self
90 }
91 pub fn headed(mut self) -> Self {
92 self.headless = false;
93 self
94 }
95 pub fn admission(mut self, hook: AdmissionHook) -> Self {
96 self.admission = Some(hook);
97 self
98 }
99 pub fn network(mut self, policy: NetworkPolicy) -> Self {
100 self.network = policy;
101 self
102 }
103 pub fn on_event(
104 mut self,
105 f: impl Fn(&crate::policy::BrowserEvent) + Send + Sync + 'static,
106 ) -> Self {
107 self.on_event = Some(std::sync::Arc::new(f));
108 self
109 }
110 #[cfg(windows)]
116 pub fn windows_job(
117 mut self,
118 job: std::os::windows::io::BorrowedHandle<'_>,
119 ) -> std::io::Result<Self> {
120 self.windows_job = Some(std::sync::Arc::new(job.try_clone_to_owned()?));
121 Ok(self)
122 }
123 pub fn named_profile(mut self, root: impl Into<PathBuf>, name: impl Into<String>) -> Self {
124 self.profile = ProfileSpec::Named {
125 root: root.into(),
126 name: name.into(),
127 };
128 self
129 }
130}
131
132pub(crate) fn validate_profile_name(name: &str) -> Result<()> {
133 let ok = !name.is_empty()
134 && name.len() <= 64
135 && !name.starts_with('.')
136 && name
137 .chars()
138 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
139 if ok {
140 Ok(())
141 } else {
142 Err(BrowserError::Profile(format!(
143 "invalid profile name '{name}'"
144 )))
145 }
146}
147
148pub const CHROME_FOR_TESTING_ENV: &str = "RIGHTKIT_CHROME_FOR_TESTING";
150
151pub const CHROME_FOR_TESTING_INSTALL_HINT: &str = "Install Chrome for Testing (any one of):\n \
153pnpm dlx @puppeteer/browsers install chrome@stable --path ~/.cache/puppeteer\n \
154pnpm exec playwright install chromium\n\
155or point RIGHTKIT_CHROME_FOR_TESTING at its executable. Regular Google Chrome is used only \
156with LaunchOptions::system_chrome(): on macOS each killed instance leaks a code-sign clone.";
157
158const MAC_CFT_APP: &str = "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing";
159
160fn home_dir() -> Option<PathBuf> {
161 std::env::var_os("HOME")
162 .or_else(|| std::env::var_os("USERPROFILE"))
163 .map(PathBuf::from)
164}
165
166fn version_key(name: &str) -> Vec<u64> {
167 name.split(|c: char| !c.is_ascii_digit())
168 .filter(|p| !p.is_empty())
169 .filter_map(|p| p.parse().ok())
170 .collect()
171}
172
173fn versioned_children(root: &Path, prefixes: &[&str]) -> Vec<PathBuf> {
175 let mut names: Vec<String> = std::fs::read_dir(root)
176 .map(|rd| {
177 rd.filter_map(|e| e.ok())
178 .map(|e| e.file_name().to_string_lossy().into_owned())
179 .filter(|n| prefixes.iter().any(|p| n.starts_with(p)))
180 .collect()
181 })
182 .unwrap_or_default();
183 names.sort_by(|a, b| version_key(b).cmp(&version_key(a)).then_with(|| b.cmp(a)));
184 names.into_iter().map(|n| root.join(n)).collect()
185}
186
187pub fn chrome_for_testing_candidates() -> Vec<PathBuf> {
191 let home = home_dir().unwrap_or_default();
192 let env_path = |k: &str| {
193 std::env::var_os(k)
194 .filter(|v| !v.is_empty())
195 .map(PathBuf::from)
196 };
197 let puppeteer = env_path("PUPPETEER_CACHE_DIR")
198 .unwrap_or_else(|| home.join(".cache").join("puppeteer"))
199 .join("chrome");
200 let mut out = Vec::new();
201 if cfg!(target_os = "macos") {
202 out.push(Path::new("/Applications").join(MAC_CFT_APP));
203 out.push(home.join("Applications").join(MAC_CFT_APP));
204 let subs: [&str; 2] = if cfg!(target_arch = "aarch64") {
205 ["chrome-mac-arm64", "chrome-mac-x64"]
206 } else {
207 ["chrome-mac-x64", "chrome-mac-arm64"]
208 };
209 let playwright = env_path("PLAYWRIGHT_BROWSERS_PATH")
210 .unwrap_or_else(|| home.join("Library/Caches/ms-playwright"));
211 for dir in versioned_children(&puppeteer, &["mac_arm-", "mac-"])
212 .into_iter()
213 .chain(versioned_children(&playwright, &["chromium-"]))
214 {
215 for sub in subs {
216 out.push(dir.join(sub).join(MAC_CFT_APP));
217 }
218 }
219 } else if cfg!(target_os = "windows") {
220 let local = env_path("LOCALAPPDATA").unwrap_or_else(|| home.join(r"AppData\Local"));
221 for dir in versioned_children(&puppeteer, &["win64-", "win32-"]) {
222 out.push(dir.join("chrome-win64").join("chrome.exe"));
223 out.push(dir.join("chrome-win32").join("chrome.exe"));
224 }
225 let playwright =
226 env_path("PLAYWRIGHT_BROWSERS_PATH").unwrap_or_else(|| local.join("ms-playwright"));
227 for dir in versioned_children(&playwright, &["chromium-"]) {
228 out.push(dir.join("chrome-win64").join("chrome.exe"));
229 }
230 } else {
231 for dir in versioned_children(&puppeteer, &["linux-", "linux_arm-"]) {
232 out.push(dir.join("chrome-linux64").join("chrome"));
233 out.push(dir.join("chrome-linux-arm64").join("chrome"));
234 }
235 let playwright = env_path("PLAYWRIGHT_BROWSERS_PATH")
236 .unwrap_or_else(|| home.join(".cache").join("ms-playwright"));
237 for dir in versioned_children(&playwright, &["chromium-"]) {
238 out.push(dir.join("chrome-linux64").join("chrome"));
239 }
240 }
241 out
242}
243
244pub fn chrome_for_testing_path(explicit: Option<&Path>) -> Result<PathBuf> {
248 let missing = |what: String| {
249 BrowserError::ChromeForTestingNotFound(format!("{what}\n{CHROME_FOR_TESTING_INSTALL_HINT}"))
250 };
251 if let Some(p) = explicit {
252 return if p.exists() {
253 Ok(p.to_path_buf())
254 } else {
255 Err(missing(format!(
256 "explicit Chrome for Testing executable does not exist: {}",
257 p.display()
258 )))
259 };
260 }
261 if let Some(p) = std::env::var_os(CHROME_FOR_TESTING_ENV).filter(|v| !v.is_empty()) {
262 let p = PathBuf::from(p);
263 return if p.exists() {
264 Ok(p)
265 } else {
266 Err(missing(format!(
267 "{CHROME_FOR_TESTING_ENV} points at a missing file: {}",
268 p.display()
269 )))
270 };
271 }
272 let candidates = chrome_for_testing_candidates();
273 if let Some(p) = candidates.iter().find(|p| p.exists()) {
274 return Ok(p.clone());
275 }
276 let searched: Vec<String> = candidates
277 .iter()
278 .map(|p| format!(" {}", p.display()))
279 .collect();
280 Err(missing(format!(
281 "Chrome for Testing is not installed; searched:\n{}",
282 if searched.is_empty() {
283 " (nothing)".to_string()
284 } else {
285 searched.join("\n")
286 }
287 )))
288}
289
290pub(crate) fn resolve_executable(opts: &LaunchOptions) -> Result<PathBuf> {
293 if let Some(p) = &opts.chrome_path {
294 return Ok(p.clone());
295 }
296 match chrome_for_testing_path(None) {
297 Ok(p) => Ok(p),
298 Err(e) if opts.allow_system_chrome => find_chrome().ok_or(e),
299 Err(e) => Err(e),
300 }
301}
302
303pub fn find_chrome() -> Option<PathBuf> {
306 if let Ok(p) = std::env::var("CHROME") {
307 let p = PathBuf::from(p);
308 if p.exists() {
309 return Some(p);
310 }
311 }
312 let candidates: &[&str] = if cfg!(target_os = "windows") {
313 &[
314 r"C:\Program Files\Google\Chrome\Application\chrome.exe",
315 r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
316 r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
317 r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
318 ]
319 } else if cfg!(target_os = "macos") {
320 &[
321 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
322 "/Applications/Chromium.app/Contents/MacOS/Chromium",
323 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
324 ]
325 } else {
326 &[
327 "/usr/bin/google-chrome",
328 "/usr/bin/chromium",
329 "/usr/bin/chromium-browser",
330 "/usr/bin/microsoft-edge",
331 ]
332 };
333 candidates
334 .iter()
335 .map(PathBuf::from)
336 .find(|p| Path::new(p).exists())
337}
338
339pub(crate) fn validate_extra_args(args: &[String]) -> Result<()> {
342 const FORBIDDEN: &[&str] = &[
343 "user-data-dir",
344 "profile-directory",
345 "remote-debugging-port",
346 "remote-debugging-address",
347 "remote-debugging-pipe",
348 "remote-allow-origins",
349 "disable-web-security",
350 "incognito-bypass",
351 ];
352 for a in args {
353 let name = a.trim_start_matches('-').split('=').next().unwrap_or("");
354 if FORBIDDEN.contains(&name) {
355 return Err(BrowserError::Invalid(format!(
356 "chrome flag '--{name}' is managed by the session"
357 )));
358 }
359 }
360 Ok(())
361}
362
363pub(crate) fn reject_real_browser_profile(root: &Path) -> Result<()> {
365 let mut real: Vec<PathBuf> = Vec::new();
366 if let Some(home) = std::env::var_os("HOME")
367 .or_else(|| std::env::var_os("USERPROFILE"))
368 .map(PathBuf::from)
369 {
370 for rel in [
371 "Library/Application Support/Google/Chrome",
372 "Library/Application Support/Chromium",
373 "Library/Application Support/Microsoft Edge",
374 ".config/google-chrome",
375 ".config/chromium",
376 ".config/microsoft-edge",
377 ] {
378 real.push(home.join(rel));
379 }
380 }
381 if let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) {
382 for rel in [
383 r"Google\Chrome\User Data",
384 r"Chromium\User Data",
385 r"Microsoft\Edge\User Data",
386 ] {
387 real.push(local.join(rel));
388 }
389 }
390 std::fs::create_dir_all(root)?;
391 let root = root.canonicalize()?;
392 for r in real {
393 if let Ok(r) = r.canonicalize() {
394 if root.starts_with(&r) {
395 return Err(BrowserError::Profile(
396 "refusing to use the user's real browser profile".into(),
397 ));
398 }
399 }
400 }
401 Ok(())
402}