use std::path::PathBuf;
use tail_fin_common::TailFinError;
#[cfg(unix)]
pub fn reap_stale_default_profile_lock() {
let temp = std::env::var_os("TMPDIR").unwrap_or_else(|| "/tmp".into());
let lock = std::path::Path::new(&temp)
.join("chromiumoxide-runner")
.join("SingletonLock");
let Ok(target) = std::fs::read_link(&lock) else {
return;
};
let target_str = target.to_string_lossy();
let Some(pid_str) = target_str.rsplit('-').next() else {
return;
};
let Ok(pid) = pid_str.parse::<i32>() else {
return;
};
if pid <= 1 {
return;
}
let alive = std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !alive {
let _ = std::fs::remove_file(&lock);
}
}
#[cfg(not(unix))]
pub fn reap_stale_default_profile_lock() {}
pub fn no_mode_error(service: &str, cmd: &str) -> TailFinError {
TailFinError::Api(format!(
"No connection mode specified for {service}.\n\
\x20 Use --connect to use browser mode:\n\
\x20 tail-fin --connect 127.0.0.1:9222 {service} {cmd}\n\
\x20 Or --cookies to use saved cookies:\n\
\x20 tail-fin --cookies auto {service} {cmd}\n\
\x20 Some adapters (e.g. spotify) auto-launch a stealth browser when no mode is given."
))
}
pub struct Ctx {
pub connect: Option<String>,
pub cookies: Option<String>,
pub headed: bool,
}
pub fn default_cookies_path(site: &str) -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home)
.join(".tail-fin")
.join(format!("{}-cookies.txt", site))
}
pub fn default_creds_path(site: &str) -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home)
.join(".tail-fin")
.join(format!("{}-creds.json", site))
}
pub fn resolve_cookies_path(cookies_flag: &str, site: &str) -> PathBuf {
if cookies_flag == "auto" {
default_cookies_path(site)
} else {
PathBuf::from(cookies_flag)
}
}
#[cfg(feature = "browser")]
pub async fn browser_session(
host: &str,
headed: bool,
) -> Result<night_fury_core::BrowserSession, TailFinError> {
Ok(night_fury_core::BrowserSession::builder()
.connect_to(format!("ws://{}", host))
.headed(headed)
.build()
.await?)
}
#[cfg(feature = "browser")]
pub async fn launch_browser(
headed: bool,
) -> Result<(tempfile::TempDir, night_fury_core::BrowserSession), TailFinError> {
launch_with_tempdir(headed).await
}
#[cfg(feature = "browser")]
pub async fn auto_launch_stealth(
url: &str,
headed: bool,
) -> Result<(tempfile::TempDir, night_fury_core::BrowserSession), TailFinError> {
eprintln!("No connection mode specified. Launching stealth browser...");
launch_stealth_with_tempdir(url, headed, Some(std::time::Duration::from_secs(30))).await
}
#[cfg(feature = "browser")]
pub async fn launch_stealth_session(
url: &str,
headed: bool,
) -> Result<(tempfile::TempDir, night_fury_core::BrowserSession), TailFinError> {
launch_stealth_with_tempdir(url, headed, None).await
}
#[cfg(feature = "browser")]
pub async fn launch_stealth_session_blocking_cf(
url: &str,
headed: bool,
cloudflare_timeout: std::time::Duration,
) -> Result<(tempfile::TempDir, night_fury_core::BrowserSession), TailFinError> {
launch_stealth_with_tempdir(url, headed, Some(cloudflare_timeout)).await
}
#[cfg(feature = "browser")]
fn profile_tempdir() -> Result<tempfile::TempDir, TailFinError> {
tempfile::Builder::new()
.prefix("tail-fin-cli-")
.tempdir()
.map_err(|e| TailFinError::Api(format!("failed to create chromium profile tempdir: {e}")))
}
#[cfg(feature = "browser")]
async fn launch_with_tempdir(
headed: bool,
) -> Result<(tempfile::TempDir, night_fury_core::BrowserSession), TailFinError> {
let profile_dir = profile_tempdir()?;
let user_data_arg = format!("--user-data-dir={}", profile_dir.path().display());
let session = night_fury_core::BrowserSession::builder()
.headed(headed)
.arg(user_data_arg)
.build()
.await?;
Ok((profile_dir, session))
}
#[cfg(feature = "browser")]
async fn launch_stealth_with_tempdir(
url: &str,
headed: bool,
cloudflare_timeout: Option<std::time::Duration>,
) -> Result<(tempfile::TempDir, night_fury_core::BrowserSession), TailFinError> {
let profile_dir = profile_tempdir()?;
let user_data_arg = format!("--user-data-dir={}", profile_dir.path().display());
let mut builder = night_fury_core::BrowserSession::builder()
.headed(headed)
.arg(user_data_arg);
if let Some(t) = cloudflare_timeout {
builder = builder.cloudflare_timeout(t);
}
let session = builder.launch_stealth(url).await?;
Ok((profile_dir, session))
}
pub fn require_browser(
connect: &Option<String>,
service: &str,
action_name: &str,
) -> Result<String, TailFinError> {
match connect {
Some(host) => Ok(host.clone()),
None => Err(TailFinError::Api(format!(
"`{service} {action_name}` requires browser mode (--connect).\n\
\x20 Use: tail-fin --connect 127.0.0.1:9222 {service} {action_name} ..."
))),
}
}
#[cfg(feature = "browser")]
pub async fn require_browser_session(
ctx: &Ctx,
service: &str,
) -> Result<night_fury_core::BrowserSession, TailFinError> {
if ctx.cookies.is_some() {
return Err(TailFinError::Api(format!(
"{service} cookie mode is not supported.\n\
\x20 Use --connect for browser mode."
)));
}
let host = match ctx.connect.as_deref() {
Some(h) => h,
None => {
return Err(TailFinError::Api(format!(
"{service} requires --connect.\n\
\x20 Example: tail-fin --connect 127.0.0.1:9222 {service} ..."
)));
}
};
browser_session(host, ctx.headed).await
}
pub fn print_json(value: &(impl serde::Serialize + ?Sized)) -> Result<(), TailFinError> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
pub fn print_list(
key: &str,
items: &impl serde::Serialize,
count: usize,
) -> Result<(), TailFinError> {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
key: items,
"count": count,
}))?
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn env_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
#[cfg(unix)]
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
env_lock().lock().unwrap_or_else(|e| e.into_inner())
}
#[cfg(unix)]
#[test]
fn reap_no_op_when_no_lock() {
let _guard = lock_env();
let td = tempfile::TempDir::new().unwrap();
let original = std::env::var_os("TMPDIR");
std::env::set_var("TMPDIR", td.path());
reap_stale_default_profile_lock();
match original {
Some(v) => std::env::set_var("TMPDIR", v),
None => std::env::remove_var("TMPDIR"),
}
}
#[cfg(unix)]
fn symlink_exists(p: &std::path::Path) -> bool {
std::fs::symlink_metadata(p).is_ok()
}
#[cfg(unix)]
#[test]
fn reap_removes_stale_symlink_pointing_at_dead_pid() {
use std::os::unix::fs::symlink;
let _guard = lock_env();
let td = tempfile::TempDir::new().unwrap();
let runner_dir = td.path().join("chromiumoxide-runner");
std::fs::create_dir_all(&runner_dir).unwrap();
let dead_pid = 999_999_999i32;
let lock = runner_dir.join("SingletonLock");
symlink(format!("fake-host-{dead_pid}"), &lock).unwrap();
let original = std::env::var_os("TMPDIR");
std::env::set_var("TMPDIR", td.path());
reap_stale_default_profile_lock();
match original {
Some(v) => std::env::set_var("TMPDIR", v),
None => std::env::remove_var("TMPDIR"),
}
assert!(
!symlink_exists(&lock),
"reaper should have unlinked dead-pid lock"
);
}
#[cfg(unix)]
#[test]
fn reap_preserves_symlink_pointing_at_live_pid() {
use std::os::unix::fs::symlink;
let _guard = lock_env();
let td = tempfile::TempDir::new().unwrap();
let runner_dir = td.path().join("chromiumoxide-runner");
std::fs::create_dir_all(&runner_dir).unwrap();
let live_pid = std::process::id() as i32;
let lock = runner_dir.join("SingletonLock");
symlink(format!("fake-host-{live_pid}"), &lock).unwrap();
let original = std::env::var_os("TMPDIR");
std::env::set_var("TMPDIR", td.path());
reap_stale_default_profile_lock();
match original {
Some(v) => std::env::set_var("TMPDIR", v),
None => std::env::remove_var("TMPDIR"),
}
assert!(
symlink_exists(&lock),
"reaper must not touch a live-pid lock"
);
}
#[test]
fn resolve_cookies_path_auto_ends_with_site_cookies_txt() {
let p = resolve_cookies_path("auto", "twitter");
assert_eq!(
p.file_name().and_then(|n| n.to_str()),
Some("twitter-cookies.txt"),
"unexpected filename in: {}",
p.display()
);
assert_eq!(
p.parent()
.and_then(|pp| pp.file_name())
.and_then(|n| n.to_str()),
Some(".tail-fin"),
"unexpected parent directory in: {}",
p.display()
);
}
#[test]
fn resolve_cookies_path_explicit_is_verbatim() {
let p = resolve_cookies_path("/explicit/cookies.txt", "twitter");
assert_eq!(p.to_string_lossy(), "/explicit/cookies.txt");
}
#[test]
fn default_creds_path_uses_tail_fin_json_name() {
let p = default_creds_path("nansen");
assert!(p.to_string_lossy().contains(".tail-fin"));
assert!(p.ends_with("nansen-creds.json"));
}
#[test]
fn require_browser_errors_when_connect_missing() {
let err = require_browser(&None, "twitter", "timeline").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("--connect"),
"error should mention --connect; got: {msg}"
);
assert!(
msg.contains("twitter timeline"),
"error should mention the service/action; got: {msg}"
);
}
#[test]
fn require_browser_returns_host_when_present() {
let host = require_browser(&Some("127.0.0.1:9222".to_string()), "twitter", "timeline")
.expect("should succeed when --connect is provided");
assert_eq!(host, "127.0.0.1:9222");
}
}