use chromiumoxide::fetcher::{BrowserVersion, Revision};
use chromiumoxide::Page;
use std::time::Duration;
use crate::error::{AppError, AppResult};
pub(crate) const HEADLESS_NAV_TIMEOUT: Duration = Duration::from_secs(60);
pub const COMPATIBLE_CHROMIUM_REVISION: u32 = 1585606;
pub(crate) fn is_terminal_cdp_error(e: &chromiumoxide::error::CdpError) -> bool {
let msg = e.to_string();
msg.contains("Inspected target navigated or closed")
|| msg.contains("No response received")
|| msg.contains("websocket")
|| msg.contains("Target closed")
|| msg.contains("Session closed")
|| msg.contains("NoResponse")
}
#[allow(dead_code)]
pub(crate) fn base_lang(language: &str) -> String {
language
.split(['-', '_', '.'])
.next()
.unwrap_or(language)
.to_lowercase()
}
#[tracing::instrument(level = "debug", err)]
pub async fn ensure_chrome() -> AppResult<String> {
if let Ok(p) = std::env::var("CHROME") {
if !p.is_empty() {
return Ok(p);
}
}
let cache_dir =
directories::ProjectDirs::from("com", "youtube-legend-cli", "youtube-legend-cli")
.map(|p| p.cache_dir().join("browser"))
.unwrap_or_else(|| std::env::temp_dir().join("yt-legend-browser"));
if let Err(e) = tokio::fs::create_dir_all(&cache_dir).await {
return Err(AppError::BrowserNotFound(format!(
"cannot create cache dir {}: {e}",
cache_dir.display()
)));
}
let fetcher = chromiumoxide::fetcher::BrowserFetcher::new(
chromiumoxide::fetcher::BrowserFetcherOptions::builder()
.with_path(&cache_dir)
.with_version(BrowserVersion::Revision(Revision::new(
COMPATIBLE_CHROMIUM_REVISION,
)))
.build()
.map_err(|e| AppError::Internal(format!("BrowserFetcherOptions: {e}")))?,
);
match fetcher.fetch().await {
Ok(info) => {
tracing::info!(
target: "events",
path = %info.executable_path.display(),
"BrowserFetcher resolved executable"
);
return info
.executable_path
.to_str()
.map(str::to_string)
.ok_or_else(|| {
AppError::BrowserNotFound(format!(
"BrowserFetcher returned non-utf8 path: {}",
info.executable_path.display()
))
});
}
Err(e) => {
tracing::warn!("BrowserFetcher download failed ({e}); falling back to system chromium");
}
}
let sys = chrome_path().ok_or_else(|| {
AppError::BrowserNotFound(
"no chromium found: BrowserFetcher download failed and no system browser in \
$CHROME or well-known paths. Ensure BrowserFetcher can download the pinned \
revision, or set $CHROME to a compatible Chromium/Chrome binary"
.to_string(),
)
})?;
tracing::warn!(
target: "events",
"using system browser at {sys} — it may be incompatible with \
chromiumoxide 0.9.1's CDP protocol; set $CHROME to the \
BrowserFetcher-pinned revision for reliable operation"
);
Ok(sys)
}
fn chrome_path() -> Option<String> {
if let Ok(p) = std::env::var("CHROME") {
if !p.is_empty() {
return Some(p);
}
}
const CANDIDATES: &[&str] = &[
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/brave-browser",
];
CANDIDATES
.iter()
.find(|c| std::path::Path::new(c).exists())
.map(|c| c.to_string())
}
pub(crate) const STEALTH_INIT_JS: &str = r#"
// Patch 1: navigator.webdriver undefined
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
// Patch 2: navigator.plugins with 3 real-looking entries
Object.defineProperty(navigator, 'plugins', {
get: () => [
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', length: 1 },
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', length: 1 },
{ name: 'Native Client', filename: 'internal-nacl-plugin', length: 2 }
]
});
// Patch 3: navigator.languages with 3 entries
Object.defineProperty(navigator, 'languages', {
get: () => ['pt-BR', 'en-US', 'en']
});
// Patch 4: WebGL vendor override (masks SwiftShader)
const origGetParameter = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function(param) {
if (param === 37445) return 'Intel Inc.';
if (param === 37446) return 'Intel Iris OpenGL Engine';
return origGetParameter.call(this, param);
};
// Patch 5: chrome.runtime mock
window.chrome = { runtime: {}, csi: () => {}, loadTimes: () => {} };
"#;
#[tracing::instrument(level = "debug", skip(page), fields(provider = tracing::field::Empty))]
pub async fn apply_stealth(page: &Page) -> crate::error::AppResult<()> {
page.evaluate_on_new_document(STEALTH_INIT_JS)
.await
.map(|_script_id| ())
.map_err(|e| crate::error::AppError::Internal(format!("apply_stealth failed: {e}")))
}
#[tracing::instrument(level = "debug")]
pub fn prepare_user_data_dir() -> Option<std::path::PathBuf> {
let base = directories::ProjectDirs::from("com", "youtube-legend-cli", "youtube-legend-cli")
.map(|p| p.cache_dir().join("chrome-profile"))
.unwrap_or_else(|| std::env::temp_dir().join("yt-legend-chrome-profile"));
if std::fs::create_dir_all(&base).is_err() {
return None;
}
for name in ["SingletonLock", "SingletonCookie", "SingletonSocket"] {
let path = base.join(name);
if let Ok(meta) = std::fs::symlink_metadata(&path) {
if meta.file_type().is_symlink() {
if let Ok(target) = std::fs::read_link(&path) {
if !is_pid_alive_from_symlink(&target) {
let _ = std::fs::remove_file(&path);
}
}
} else {
if let Ok(modified) = meta.modified() {
if modified.elapsed().unwrap_or_default()
> std::time::Duration::from_secs(3600)
{
let _ = std::fs::remove_file(&path);
}
}
}
}
}
Some(base)
}
#[cfg(unix)]
fn is_pid_alive_from_symlink(target: &std::path::Path) -> bool {
let Some(file_name) = target.file_name().and_then(|n| n.to_str()) else {
return false;
};
let digits: String = file_name
.chars()
.rev()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.chars()
.rev()
.collect();
let Ok(pid) = digits.parse::<i32>() else {
return false;
};
if pid <= 0 {
return false;
}
std::process::Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_pid_alive_from_symlink(_target: &std::path::Path) -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stealth_init_js_masks_webdriver() {
assert!(
STEALTH_INIT_JS.contains("navigator, 'webdriver'"),
"Patch 1 (navigator.webdriver) is missing — Cloudflare Turnstile \
will detect headless Chromium."
);
}
#[test]
fn stealth_init_js_pollutes_plugins() {
assert!(
STEALTH_INIT_JS.contains("navigator, 'plugins'"),
"Patch 2 (navigator.plugins) is missing — fingerprint will report \
zero plugins (distinctive headless signature)."
);
assert!(
STEALTH_INIT_JS.contains("Chrome PDF Plugin"),
"Plugin list is empty — the three default Chromium plugins must be listed."
);
}
#[test]
fn stealth_init_js_overrides_languages() {
assert!(
STEALTH_INIT_JS.contains("navigator, 'languages'"),
"Patch 3 (navigator.languages) is missing — fingerprint will report \
['en-US'] (distinctive headless signature)."
);
assert!(
STEALTH_INIT_JS.contains("pt-BR"),
"languages must include pt-BR to match Brazilian operator locale."
);
}
#[test]
fn stealth_init_js_overrides_webgl_vendor() {
assert!(
STEALTH_INIT_JS.contains("37445"),
"Patch 4 (WebGL UNMASKED_VENDOR_WEBGL constant) is missing — \
fingerprint will report 'Google Inc. (SwiftShader)'."
);
assert!(
STEALTH_INIT_JS.contains("37446"),
"Patch 4 (WebGL UNMASKED_RENDERER_WEBGL constant) is missing — \
fingerprint will report the SwiftShader renderer string."
);
assert!(
STEALTH_INIT_JS.contains("Intel Inc."),
"WebGL vendor override target is wrong — must impersonate Intel."
);
}
#[test]
fn stealth_init_js_mocks_chrome_runtime() {
assert!(
STEALTH_INIT_JS.contains("chrome.runtime"),
"Patch 5 (window.chrome.runtime mock) is missing — fingerprint will \
see `window.chrome === undefined`."
);
assert!(
STEALTH_INIT_JS.contains("runtime: {}"),
"chrome.runtime mock must be an empty object, not undefined."
);
}
#[test]
fn headless_nav_timeout_is_60s() {
assert_eq!(HEADLESS_NAV_TIMEOUT, Duration::from_secs(60));
}
#[test]
fn base_lang_reduces_region_tags() {
assert_eq!(base_lang("pt-BR"), "pt");
assert_eq!(base_lang("pt_BR.UTF-8"), "pt");
assert_eq!(base_lang("EN"), "en");
assert_eq!(base_lang("es"), "es");
}
#[test]
fn compatible_chromium_revision_is_pinned() {
const { assert!(COMPATIBLE_CHROMIUM_REVISION > 0) };
const { assert!(COMPATIBLE_CHROMIUM_REVISION > 1_000_000) };
}
#[test]
fn browser_version_constructs_from_pinned_revision() {
let version = BrowserVersion::Revision(Revision::new(COMPATIBLE_CHROMIUM_REVISION));
let display = format!("{version:?}");
assert!(
display.contains("Revision"),
"expected Revision variant in {display}"
);
assert!(
display.contains(&COMPATIBLE_CHROMIUM_REVISION.to_string()),
"expected pinned revision in {display}"
);
}
}