use std::{
env, fmt,
fs::{self, File, OpenOptions},
io::ErrorKind,
path::{Path, PathBuf},
time::Duration,
};
use fd_lock::{RwLock as FileLock, RwLockWriteGuard};
use tokio::time::{Instant, sleep};
use crate::{
error::{Result, VoidCrawlError},
session::{BrowserSession, BrowserSessionBuilder},
};
#[derive(Debug, Clone)]
pub struct ProfileInfo {
pub name: String,
pub path: PathBuf,
}
pub struct ProfileHandle {
name: String,
path: PathBuf,
session: Option<BrowserSession>,
_lock: RwLockWriteGuard<'static, File>,
}
impl fmt::Debug for ProfileHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProfileHandle")
.field("name", &self.name)
.field("path", &self.path)
.finish_non_exhaustive()
}
}
impl ProfileHandle {
pub fn name(&self) -> &str {
&self.name
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn session(&self) -> Result<&BrowserSession> {
self.session.as_ref().ok_or(VoidCrawlError::BrowserClosed)
}
pub async fn close(&mut self) -> Result<()> {
if let Some(session) = self.session.take() {
session.close().await?;
}
Ok(())
}
pub fn take_session(&mut self) -> Option<BrowserSession> {
self.session.take()
}
}
pub fn list_profiles() -> Result<Vec<ProfileInfo>> {
let bases = chrome_user_data_dirs();
let mut out = Vec::new();
for base in &bases {
if !base.is_dir() {
continue;
}
let entries = fs::read_dir(base)
.map_err(|e| VoidCrawlError::Other(format!("read_dir {}: {e}", base.display())))?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if !is_profile_dir(&path) {
continue;
}
out.push(ProfileInfo { name: name.to_string(), path });
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub fn resolve_profile(name: &str) -> Result<PathBuf> {
let bases = chrome_user_data_dirs();
let mut searched = Vec::new();
for base in &bases {
searched.push(base.display().to_string());
let candidate = base.join(name);
if is_profile_dir(&candidate) {
return Ok(candidate);
}
}
Err(VoidCrawlError::ProfileNotFound { name: name.to_string(), searched })
}
pub async fn acquire_profile(
name: &str,
lease_timeout: Duration,
headless: bool,
) -> Result<ProfileHandle> {
acquire_profile_in(name, &chrome_user_data_dirs(), lease_timeout, headless).await
}
pub async fn acquire_profile_in(
name: &str,
bases: &[PathBuf],
lease_timeout: Duration,
headless: bool,
) -> Result<ProfileHandle> {
let mut searched = Vec::new();
let mut resolved: Option<(PathBuf, PathBuf)> = None;
for base in bases {
searched.push(base.display().to_string());
let candidate = base.join(name);
if is_profile_dir(&candidate) {
resolved = Some((base.clone(), candidate));
break;
}
}
let (user_data_dir, path) = resolved
.ok_or_else(|| VoidCrawlError::ProfileNotFound { name: name.to_string(), searched })?;
let lock_path = path.join(".voidcrawl.lock");
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.map_err(|e| VoidCrawlError::Other(format!("open {}: {e}", lock_path.display())))?;
struct SendPtr(*mut FileLock<File>);
unsafe impl Send for SendPtr {}
let lock_box: Box<FileLock<File>> = Box::new(FileLock::new(file));
let lock_ptr = SendPtr(Box::leak(lock_box));
let deadline = Instant::now() + lease_timeout;
let guard: RwLockWriteGuard<'static, File> = loop {
let attempt = unsafe { (*lock_ptr.0).try_write() };
match attempt {
Ok(g) => break g,
Err(e) if e.kind() == ErrorKind::WouldBlock => {}
Err(e) => {
return Err(VoidCrawlError::Other(format!("lock {}: {e}", lock_path.display())));
}
}
if Instant::now() >= deadline {
return Err(if lease_timeout.is_zero() {
VoidCrawlError::ProfileBusy { name: name.to_string() }
} else {
VoidCrawlError::ProfileLeaseExpired {
name: name.to_string(),
timeout_secs: lease_timeout.as_secs(),
}
});
}
sleep(Duration::from_millis(100)).await;
};
let mut builder = BrowserSessionBuilder::new()
.user_data_dir(&user_data_dir)
.arg(format!("--profile-directory={name}"));
builder = if headless { builder.headless() } else { builder.headful() };
let session = builder.launch().await?;
Ok(ProfileHandle { name: name.to_string(), path, session: Some(session), _lock: guard })
}
pub async fn release_profile(mut handle: ProfileHandle) -> Result<()> {
handle.close().await
}
fn is_profile_dir(path: &Path) -> bool {
path.is_dir() && path.join("Preferences").is_file()
}
pub fn chrome_user_data_dirs() -> Vec<PathBuf> {
let mut out = Vec::new();
#[cfg(target_os = "linux")]
{
let config = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")));
if let Some(config) = config {
out.push(config.join("google-chrome"));
out.push(config.join("chromium"));
out.push(config.join("google-chrome-beta"));
out.push(config.join("google-chrome-unstable"));
}
}
#[cfg(target_os = "macos")]
{
if let Some(home) = env::var_os("HOME").map(PathBuf::from) {
let app_sup = home.join("Library").join("Application Support");
out.push(app_sup.join("Google").join("Chrome"));
out.push(app_sup.join("Chromium"));
out.push(app_sup.join("Google").join("Chrome Canary"));
}
}
#[cfg(target_os = "windows")]
{
if let Some(local) = env::var_os("LOCALAPPDATA").map(PathBuf::from) {
out.push(local.join("Google").join("Chrome").join("User Data"));
out.push(local.join("Chromium").join("User Data"));
}
}
out
}