use std::{
env,
ffi::OsString,
path::{Path, PathBuf},
process::ExitStatus,
};
use chrome_locations::{get_any_chrome_stable, get_chrome_path};
use tempfile::{Builder, TempDir};
use tokio::{
fs,
io::{AsyncBufReadExt, BufReader},
process::{Child, ChildStdin, ChildStdout, Command},
time::{sleep, timeout, Duration, Instant},
};
use crate::{
error::LaunchError,
options::{LaunchOptions, TransportMode},
};
#[derive(Debug, Clone)]
pub struct Launcher {
options: LaunchOptions,
}
impl Launcher {
#[must_use]
pub fn new(options: LaunchOptions) -> Self {
Self { options }
}
pub async fn launch(self) -> Result<LaunchedChrome, LaunchError> {
let executable_path = resolve_executable_path(self.options.executable_path.as_deref())?;
let profile = prepare_profile_dir(self.options.user_data_dir.as_deref()).await?;
let launch_kind = self.options.transport_mode;
let log_browser_stderr = debug_enabled("browser");
let mut command = build_command(
&executable_path,
profile.path(),
launch_kind,
self.options.headless,
&self.options.args,
log_browser_stderr,
);
let mut process = spawn_chrome(&mut command)?;
if log_browser_stderr {
spawn_browser_stderr_logger(&mut process);
}
let connection = match launch_kind {
TransportMode::WebSocket => {
let devtools = wait_for_devtools_active_port(
profile.path(),
self.options.startup_timeout,
|| process.try_wait(),
)
.await?;
LaunchConnection::WebSocket {
browser_websocket_url: devtools.browser_websocket_url,
browser_websocket_path: devtools.browser_websocket_path,
port: devtools.port,
}
}
TransportMode::Pipe => {
let (stdin, stdout) = process
.take_pipe_handles()
.ok_or(LaunchError::MissingPipeHandles)?;
LaunchConnection::Pipe { stdin, stdout }
}
};
Ok(LaunchedChrome {
executable_path,
profile,
process,
connection,
})
}
}
pub struct LaunchedChrome {
executable_path: PathBuf,
profile: ProfileDir,
process: ChromeProcess,
connection: LaunchConnection,
}
impl LaunchedChrome {
#[must_use]
pub fn executable_path(&self) -> &Path {
&self.executable_path
}
#[must_use]
pub fn profile_dir(&self) -> &Path {
self.profile.path()
}
#[must_use]
pub fn transport_mode(&self) -> TransportMode {
self.connection.transport_mode()
}
#[must_use]
pub fn websocket_endpoint(&self) -> Option<&str> {
match &self.connection {
LaunchConnection::WebSocket {
browser_websocket_url,
..
} => Some(browser_websocket_url.as_str()),
LaunchConnection::Pipe { .. } => None,
}
}
pub fn into_parts(self) -> LaunchedChromeParts {
let Self {
executable_path,
profile,
process,
connection,
} = self;
LaunchedChromeParts {
executable_path,
profile,
child: process.into_inner(),
connection,
}
}
pub async fn shutdown(self) -> Result<(), LaunchError> {
self.process.shutdown().await;
Ok(())
}
}
pub struct LaunchedChromeParts {
pub executable_path: PathBuf,
pub profile: ProfileDir,
pub child: Child,
pub connection: LaunchConnection,
}
impl LaunchedChromeParts {
#[must_use]
pub fn transport_mode(&self) -> TransportMode {
self.connection.transport_mode()
}
}
pub enum LaunchConnection {
WebSocket {
browser_websocket_url: String,
browser_websocket_path: String,
port: u16,
},
Pipe {
stdin: ChildStdin,
stdout: ChildStdout,
},
}
impl LaunchConnection {
#[must_use]
pub fn transport_mode(&self) -> TransportMode {
match self {
Self::WebSocket { .. } => TransportMode::WebSocket,
Self::Pipe { .. } => TransportMode::Pipe,
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct DevToolsActivePort {
pub port: u16,
pub browser_websocket_path: String,
pub browser_websocket_url: String,
}
fn build_command(
executable_path: &Path,
user_data_dir: &Path,
transport_mode: TransportMode,
headless: bool,
args: &[OsString],
log_browser_stderr: bool,
) -> Command {
let mut command = Command::new(executable_path);
command.arg(format!("--user-data-dir={}", user_data_dir.display()));
command.arg("--no-first-run");
command.arg("--no-default-browser-check");
if headless {
command.arg("--headless=new");
}
match transport_mode {
TransportMode::WebSocket => {
command.arg("--remote-debugging-port=0");
command.stdout(std::process::Stdio::null());
if log_browser_stderr {
command.stderr(std::process::Stdio::piped());
} else {
command.stderr(std::process::Stdio::null());
}
command.stdin(std::process::Stdio::null());
}
TransportMode::Pipe => {
command.arg("--remote-debugging-pipe");
command.stdin(std::process::Stdio::piped());
command.stdout(std::process::Stdio::piped());
if log_browser_stderr {
command.stderr(std::process::Stdio::piped());
} else {
command.stderr(std::process::Stdio::null());
}
}
}
command.args(args);
command
}
fn debug_enabled(scope: &str) -> bool {
env::var("DEBUG")
.ok()
.map(|value| {
value
.split(',')
.map(str::trim)
.any(|entry| entry == "all" || entry == scope)
})
.unwrap_or(false)
}
fn spawn_browser_stderr_logger(process: &mut ChromeProcess) {
let Some(stderr) = process.child_mut().and_then(|child| child.stderr.take()) else {
return;
};
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
loop {
match lines.next_line().await {
Ok(Some(line)) => {
let line = line.trim();
if !line.is_empty() {
tracing::info!(target: "playhard::browser", line = %line, "browser stderr");
}
}
Ok(None) => break,
Err(error) => {
tracing::warn!(
target: "playhard::browser",
error = %error,
"failed to read browser stderr"
);
break;
}
}
}
});
}
fn spawn_chrome(command: &mut Command) -> Result<ChromeProcess, LaunchError> {
command
.spawn()
.map(ChromeProcess::new)
.map_err(|source| LaunchError::Spawn { source })
}
fn resolve_executable_path(explicit_path: Option<&Path>) -> Result<PathBuf, LaunchError> {
if let Some(path) = explicit_path {
if path.exists() {
return Ok(path.to_path_buf());
}
return Err(LaunchError::ExecutableNotFound(path.to_path_buf()));
}
match get_chrome_path() {
Ok(path) => Ok(path),
Err(primary) => match get_any_chrome_stable() {
Ok(path) => Ok(path),
Err(fallback) => Err(LaunchError::ChromeNotFound {
primary: primary.to_string(),
fallback: fallback.to_string(),
}),
},
}
}
async fn prepare_profile_dir(explicit_profile: Option<&Path>) -> Result<ProfileDir, LaunchError> {
if let Some(path) = explicit_profile {
fs::create_dir_all(path)
.await
.map_err(|source| LaunchError::ProfileDirectory { source })?;
return Ok(ProfileDir::Borrowed(path.to_path_buf()));
}
let temp_dir = Builder::new()
.prefix("playhard-chrome-profile-")
.tempdir()
.map_err(|source| LaunchError::ProfileDirectory { source })?;
Ok(ProfileDir::Owned(temp_dir))
}
pub enum ProfileDir {
Borrowed(PathBuf),
Owned(TempDir),
}
struct ChromeProcess {
child: Option<Child>,
}
impl ChromeProcess {
fn new(child: Child) -> Self {
Self { child: Some(child) }
}
fn child_mut(&mut self) -> Option<&mut Child> {
self.child.as_mut()
}
fn try_wait(&mut self) -> Result<Option<ExitStatus>, std::io::Error> {
match self.child.as_mut() {
Some(child) => child.try_wait(),
None => Ok(None),
}
}
fn take_pipe_handles(&mut self) -> Option<(ChildStdin, ChildStdout)> {
let child = self.child.as_mut()?;
let stdin = child.stdin.take()?;
let stdout = child.stdout.take()?;
Some((stdin, stdout))
}
fn into_inner(mut self) -> Child {
match self.child.take() {
Some(child) => child,
None => unreachable!("chrome process child must exist until ownership is transferred"),
}
}
async fn shutdown(mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.start_kill();
let _ = timeout(Duration::from_secs(5), child.wait()).await;
}
}
}
impl Drop for ChromeProcess {
fn drop(&mut self) {
if let Some(child) = self.child.as_mut() {
let _ = child.start_kill();
}
}
}
impl ProfileDir {
#[must_use]
pub fn path(&self) -> &Path {
match self {
Self::Borrowed(path) => path.as_path(),
Self::Owned(tempdir) => tempdir.path(),
}
}
}
pub(crate) async fn wait_for_devtools_active_port<F>(
profile_dir: &Path,
startup_timeout: Duration,
mut try_wait: F,
) -> Result<DevToolsActivePort, LaunchError>
where
F: FnMut() -> Result<Option<ExitStatus>, std::io::Error>,
{
let path = profile_dir.join("DevToolsActivePort");
let deadline = Instant::now() + startup_timeout;
loop {
if let Some(status) = try_wait().map_err(|source| LaunchError::ProcessState { source })? {
let _ = status;
return Err(LaunchError::ChromeExitedEarly);
}
match fs::read_to_string(&path).await {
Ok(contents) => match parse_devtools_active_port(&path, &contents) {
Ok(parsed) => return Ok(parsed),
Err(LaunchError::InvalidDevToolsActivePort { .. }) => {}
Err(error) => return Err(error),
},
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
Err(source) => {
return Err(LaunchError::DevToolsActivePortRead {
path: path.clone(),
source,
});
}
}
if Instant::now() >= deadline {
return Err(LaunchError::DevToolsActivePortTimeout {
path,
timeout: startup_timeout,
});
}
sleep(Duration::from_millis(50)).await;
}
}
pub fn parse_devtools_active_port(
path: &Path,
contents: &str,
) -> Result<DevToolsActivePort, LaunchError> {
let mut lines = contents.lines();
let Some(port_line) = lines.next() else {
return Err(LaunchError::InvalidDevToolsActivePort {
path: path.to_path_buf(),
});
};
let Some(path_line) = lines.next() else {
return Err(LaunchError::InvalidDevToolsActivePort {
path: path.to_path_buf(),
});
};
let port = port_line
.parse::<u16>()
.map_err(|_| LaunchError::InvalidDevToolsActivePort {
path: path.to_path_buf(),
})?;
let browser_websocket_path = path_line.to_owned();
let browser_websocket_url = format!("ws://127.0.0.1:{port}{browser_websocket_path}");
Ok(DevToolsActivePort {
port,
browser_websocket_path,
browser_websocket_url,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::tempdir;
#[test]
fn parse_devtools_active_port_returns_browser_websocket_url() {
let dir = PathBuf::from("/tmp/profile");
let parsed = parse_devtools_active_port(&dir, "9222\n/devtools/browser/abc123\n")
.expect("valid active port file");
assert_eq!(parsed.port, 9222);
assert_eq!(parsed.browser_websocket_path, "/devtools/browser/abc123");
assert_eq!(
parsed.browser_websocket_url,
"ws://127.0.0.1:9222/devtools/browser/abc123"
);
}
#[test]
fn parse_devtools_active_port_rejects_invalid_content() {
let dir = PathBuf::from("/tmp/profile");
let err = parse_devtools_active_port(&dir, "not-a-port\n").expect_err("invalid");
assert!(matches!(err, LaunchError::InvalidDevToolsActivePort { .. }));
}
#[test]
fn build_command_uses_websocket_port_by_default() {
let command = build_command(
Path::new("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
Path::new("/tmp/playhard-profile"),
TransportMode::WebSocket,
false,
&[],
false,
);
let args = command
.as_std()
.get_args()
.map(|value| value.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert!(args.iter().any(|arg| arg == "--remote-debugging-port=0"));
assert!(!args.iter().any(|arg| arg == "--remote-debugging-pipe"));
}
#[test]
fn build_command_uses_pipe_mode_when_requested() {
let command = build_command(
Path::new("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
Path::new("/tmp/playhard-profile"),
TransportMode::Pipe,
true,
&[],
false,
);
let args = command
.as_std()
.get_args()
.map(|value| value.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert!(args.iter().any(|arg| arg == "--remote-debugging-pipe"));
assert!(args.iter().any(|arg| arg == "--headless=new"));
}
#[tokio::test]
async fn prepare_profile_dir_creates_borrowed_directory() {
let dir = tempdir().expect("tempdir");
let borrowed = prepare_profile_dir(Some(dir.path()))
.await
.expect("profile dir");
match borrowed {
ProfileDir::Borrowed(path) => assert_eq!(path, dir.path()),
ProfileDir::Owned(_) => panic!("expected borrowed profile dir"),
}
}
#[test]
fn debug_enabled_only_allows_browser_or_all() {
let original = env::var_os("DEBUG");
env::remove_var("DEBUG");
assert!(!debug_enabled("browser"));
env::set_var("DEBUG", "protocol,browser");
assert!(debug_enabled("browser"));
assert!(!debug_enabled("transport"));
env::set_var("DEBUG", "all");
assert!(debug_enabled("browser"));
assert!(debug_enabled("transport"));
match original {
Some(value) => env::set_var("DEBUG", value),
None => env::remove_var("DEBUG"),
}
}
}