use std::process::Stdio;
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use serde_json::json;
use tokio::sync::broadcast;
use crate::cdp::{CdpClient, CdpEvent};
use crate::detect::Engine;
use crate::registry::{ForegroundRow, Registry};
use crate::session::backend::{open_backend, TabBackend};
const SPAWN_WAIT: Duration = Duration::from_secs(8);
const SPAWN_POLL: Duration = Duration::from_millis(50);
pub async fn apply(client: &CdpClient, session_id: &str, enabled: bool) -> Result<()> {
client
.send_with_session(
"Emulation.setFocusEmulationEnabled",
json!({ "enabled": enabled }),
Some(session_id),
)
.await?;
let idle = if enabled {
client
.send_with_session(
"Emulation.setIdleOverride",
json!({ "isUserActive": true, "isScreenUnlocked": true }),
Some(session_id),
)
.await
} else {
client
.send_with_session("Emulation.clearIdleOverride", json!({}), Some(session_id))
.await
};
if let Err(e) = idle {
tracing::debug!(error = %e, "idle override unavailable");
}
Ok(())
}
pub fn status(
registry: &Registry,
browser_name: &str,
target_id: &str,
) -> Result<Option<ForegroundRow>> {
registry.foreground_get(browser_name, target_id)
}
pub fn active_targets(registry: &Registry, browser_name: &str) -> Result<Vec<String>> {
Ok(registry
.foreground_list(browser_name)?
.into_iter()
.map(|r| r.target_id)
.collect())
}
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60 * 60);
pub fn spawn_holder(
registry: &Registry,
browser_name: &str,
target_id: &str,
timeout: Duration,
) -> Result<(u32, bool)> {
if let Some(existing) = registry.foreground_get(browser_name, target_id)? {
return Ok((existing.pid, false));
}
let exe = std::env::var_os("BROWSER_CONTROL_BIN")
.map(std::path::PathBuf::from)
.map(Ok)
.unwrap_or_else(std::env::current_exe)
.context("locating the browser-control executable")?;
let mut cmd = std::process::Command::new(exe);
cmd.args([
"tab",
"foreground-hold",
browser_name,
target_id,
"--timeout-s",
&timeout.as_secs().to_string(),
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
const DETACHED_PROCESS: u32 = 0x0000_0008;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
}
let mut child = cmd.spawn().context("spawning the foreground holder")?;
let pid = child.id();
let deadline = std::time::Instant::now() + SPAWN_WAIT;
loop {
if let Some(row) = registry.foreground_get(browser_name, target_id)? {
if row.pid == pid {
return Ok((pid, true));
}
}
if let Some(status) = child.try_wait()? {
bail!(
"foreground holder exited before attaching ({status}); is the tab still open and the browser a Chromium?"
);
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
bail!("foreground holder did not attach within {SPAWN_WAIT:?}");
}
std::thread::sleep(SPAWN_POLL);
}
}
pub fn stop_holder(registry: &Registry, browser_name: &str, target_id: &str) -> Result<bool> {
let Some(row) = registry.foreground_get(browser_name, target_id)? else {
return Ok(false);
};
terminate(row.pid);
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while crate::registry::pid_alive(row.pid) && std::time::Instant::now() < deadline {
std::thread::sleep(SPAWN_POLL);
}
registry.foreground_delete(browser_name, target_id)?;
Ok(true)
}
pub fn stop_all(registry: &Registry, browser_name: &str) -> Result<usize> {
let rows = registry.foreground_list(browser_name)?;
let mut n = 0;
for r in rows {
if stop_holder(registry, browser_name, &r.target_id)? {
n += 1;
}
}
Ok(n)
}
fn terminate(pid: u32) {
#[cfg(unix)]
{
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
#[cfg(not(unix))]
{
let mut sys = sysinfo::System::new();
let p = sysinfo::Pid::from_u32(pid);
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[p]), true);
if let Some(proc_) = sys.process(p) {
proc_.kill();
}
}
}
pub async fn hold(browser_name: &str, target_id: &str, timeout: Duration) -> Result<()> {
let registry = Registry::open()?;
let row = registry
.get_by_name(browser_name)?
.ok_or_else(|| anyhow!("no registered browser named {browser_name}"))?;
if row.engine != Engine::Cdp {
bail!("foreground emulation is Chromium-only (Firefox has no BiDi equivalent)");
}
let TabBackend::Cdp(client) = open_backend(&row.endpoint, Engine::Cdp).await? else {
bail!("expected a CDP backend");
};
let mut events = client.subscribe();
let _ = client
.send("Target.setDiscoverTargets", json!({ "discover": true }))
.await;
let session_id = client.attach_to_target(target_id).await?;
let _ = client
.send_with_session("Inspector.enable", json!({}), Some(&session_id))
.await;
apply(&client, &session_id, true).await?;
let expires_at = crate::registry::now_epoch_s() + timeout.as_secs() as i64;
registry.foreground_upsert(browser_name, target_id, std::process::id(), expires_at)?;
tracing::info!(
browser = browser_name,
target = target_id,
?timeout,
"foreground emulation held"
);
let reason = wait_for_exit(&mut events, target_id, &session_id, timeout).await;
tracing::info!(browser = browser_name, target = target_id, %reason, "foreground holder exiting");
let _ = tokio::time::timeout(Duration::from_secs(2), async {
let _ = apply(&client, &session_id, false).await;
let _ = client
.send(
"Target.detachFromTarget",
json!({ "sessionId": session_id }),
)
.await;
})
.await;
registry.foreground_delete(browser_name, target_id)?;
Ok(())
}
async fn wait_for_exit(
events: &mut broadcast::Receiver<CdpEvent>,
target_id: &str,
session_id: &str,
timeout: Duration,
) -> &'static str {
let stop = stop_signal();
tokio::pin!(stop);
let expiry = tokio::time::sleep(timeout);
tokio::pin!(expiry);
loop {
tokio::select! {
_ = &mut stop => return "stop requested",
_ = &mut expiry => return "timeout reached",
ev = events.recv() => match ev {
Ok(ev) => {
let ours_session = ev.session_id.as_deref() == Some(session_id);
match ev.method.as_str() {
"Target.targetDestroyed" | "Target.targetCrashed"
if ev.params["targetId"].as_str() == Some(target_id) =>
{
return "tab closed";
}
"Target.detachedFromTarget"
if ev.params["sessionId"].as_str() == Some(session_id) =>
{
return "session detached";
}
"Inspector.detached" | "Inspector.targetCrashed" if ours_session => {
return "tab detached";
}
_ => {}
}
}
Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Closed) => return "browser connection closed",
},
}
}
}
#[cfg(unix)]
async fn stop_signal() {
use tokio::signal::unix::{signal, SignalKind};
let mut term = match signal(SignalKind::terminate()) {
Ok(s) => s,
Err(_) => {
let _ = tokio::signal::ctrl_c().await;
return;
}
};
tokio::select! {
_ = term.recv() => {}
_ = tokio::signal::ctrl_c() => {}
}
}
#[cfg(not(unix))]
async fn stop_signal() {
let _ = tokio::signal::ctrl_c().await;
}