browser_control/session/
foreground.rs1use std::process::Stdio;
21use std::time::Duration;
22
23use anyhow::{anyhow, bail, Context, Result};
24use serde_json::json;
25use tokio::sync::broadcast;
26
27use crate::cdp::{CdpClient, CdpEvent};
28use crate::detect::Engine;
29use crate::registry::{ForegroundRow, Registry};
30use crate::session::backend::{open_backend, TabBackend};
31
32const SPAWN_WAIT: Duration = Duration::from_secs(8);
34const SPAWN_POLL: Duration = Duration::from_millis(50);
35
36pub async fn apply(client: &CdpClient, session_id: &str, enabled: bool) -> Result<()> {
42 client
43 .send_with_session(
44 "Emulation.setFocusEmulationEnabled",
45 json!({ "enabled": enabled }),
46 Some(session_id),
47 )
48 .await?;
49 let idle = if enabled {
50 client
51 .send_with_session(
52 "Emulation.setIdleOverride",
53 json!({ "isUserActive": true, "isScreenUnlocked": true }),
54 Some(session_id),
55 )
56 .await
57 } else {
58 client
59 .send_with_session("Emulation.clearIdleOverride", json!({}), Some(session_id))
60 .await
61 };
62 if let Err(e) = idle {
63 tracing::debug!(error = %e, "idle override unavailable");
64 }
65 Ok(())
66}
67
68pub fn status(
70 registry: &Registry,
71 browser_name: &str,
72 target_id: &str,
73) -> Result<Option<ForegroundRow>> {
74 registry.foreground_get(browser_name, target_id)
75}
76
77pub fn active_targets(registry: &Registry, browser_name: &str) -> Result<Vec<String>> {
79 Ok(registry
80 .foreground_list(browser_name)?
81 .into_iter()
82 .map(|r| r.target_id)
83 .collect())
84}
85
86pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60 * 60);
90
91pub fn spawn_holder(
94 registry: &Registry,
95 browser_name: &str,
96 target_id: &str,
97 timeout: Duration,
98) -> Result<(u32, bool)> {
99 if let Some(existing) = registry.foreground_get(browser_name, target_id)? {
100 return Ok((existing.pid, false));
101 }
102 let exe = std::env::var_os("BROWSER_CONTROL_BIN")
103 .map(std::path::PathBuf::from)
104 .map(Ok)
105 .unwrap_or_else(std::env::current_exe)
106 .context("locating the browser-control executable")?;
107 let mut cmd = std::process::Command::new(exe);
108 cmd.args([
109 "tab",
110 "foreground-hold",
111 browser_name,
112 target_id,
113 "--timeout-s",
114 &timeout.as_secs().to_string(),
115 ])
116 .stdin(Stdio::null())
117 .stdout(Stdio::null())
118 .stderr(Stdio::null());
119 #[cfg(unix)]
120 {
121 use std::os::unix::process::CommandExt;
122 cmd.process_group(0);
125 }
126 #[cfg(windows)]
127 {
128 use std::os::windows::process::CommandExt;
129 const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
130 const DETACHED_PROCESS: u32 = 0x0000_0008;
131 cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
132 }
133 let mut child = cmd.spawn().context("spawning the foreground holder")?;
134 let pid = child.id();
135 let deadline = std::time::Instant::now() + SPAWN_WAIT;
136 loop {
137 if let Some(row) = registry.foreground_get(browser_name, target_id)? {
138 if row.pid == pid {
139 std::thread::Builder::new()
143 .name("foreground-holder-reaper".into())
144 .spawn(move || {
145 let _ = child.wait();
146 })
147 .ok();
148 return Ok((pid, true));
149 }
150 }
151 if let Some(status) = child.try_wait()? {
152 bail!(
153 "foreground holder exited before attaching ({status}); is the tab still open and the browser a Chromium?"
154 );
155 }
156 if std::time::Instant::now() >= deadline {
157 let _ = child.kill();
158 bail!("foreground holder did not attach within {SPAWN_WAIT:?}");
159 }
160 std::thread::sleep(SPAWN_POLL);
161 }
162}
163
164pub fn stop_holder(registry: &Registry, browser_name: &str, target_id: &str) -> Result<bool> {
166 let Some(row) = registry.foreground_get(browser_name, target_id)? else {
167 return Ok(false);
168 };
169 terminate(row.pid);
170 let deadline = std::time::Instant::now() + Duration::from_secs(3);
173 while crate::registry::pid_alive(row.pid) && std::time::Instant::now() < deadline {
174 std::thread::sleep(SPAWN_POLL);
175 }
176 registry.foreground_delete(browser_name, target_id)?;
177 Ok(true)
178}
179
180pub fn stop_all(registry: &Registry, browser_name: &str) -> Result<usize> {
182 let rows = registry.foreground_list(browser_name)?;
183 let mut n = 0;
184 for r in rows {
185 if stop_holder(registry, browser_name, &r.target_id)? {
186 n += 1;
187 }
188 }
189 Ok(n)
190}
191
192fn terminate(pid: u32) {
196 #[cfg(unix)]
197 {
198 unsafe {
200 libc::kill(pid as i32, libc::SIGTERM);
201 }
202 }
203 #[cfg(not(unix))]
204 {
205 let mut sys = sysinfo::System::new();
206 let p = sysinfo::Pid::from_u32(pid);
207 sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[p]), true);
208 if let Some(proc_) = sys.process(p) {
209 proc_.kill();
210 }
211 }
212}
213
214pub async fn hold(browser_name: &str, target_id: &str, timeout: Duration) -> Result<()> {
217 let registry = Registry::open()?;
218 let row = registry
219 .get_by_name(browser_name)?
220 .ok_or_else(|| anyhow!("no registered browser named {browser_name}"))?;
221 if row.engine != Engine::Cdp {
222 bail!("foreground emulation is Chromium-only (Firefox has no BiDi equivalent)");
223 }
224 let TabBackend::Cdp(client) = open_backend(&row.endpoint, Engine::Cdp).await? else {
225 bail!("expected a CDP backend");
226 };
227 let mut events = client.subscribe();
228 let _ = client
230 .send("Target.setDiscoverTargets", json!({ "discover": true }))
231 .await;
232 let session_id = client.attach_to_target(target_id).await?;
233 let _ = client
234 .send_with_session("Inspector.enable", json!({}), Some(&session_id))
235 .await;
236 apply(&client, &session_id, true).await?;
237 let expires_at = crate::registry::now_epoch_s() + timeout.as_secs() as i64;
238 registry.foreground_upsert(browser_name, target_id, std::process::id(), expires_at)?;
239 tracing::info!(
240 browser = browser_name,
241 target = target_id,
242 ?timeout,
243 "foreground emulation held"
244 );
245
246 let reason = wait_for_exit(&mut events, target_id, &session_id, timeout).await;
247 tracing::info!(browser = browser_name, target = target_id, %reason, "foreground holder exiting");
248 let _ = tokio::time::timeout(Duration::from_secs(2), async {
249 let _ = apply(&client, &session_id, false).await;
250 let _ = client
251 .send(
252 "Target.detachFromTarget",
253 json!({ "sessionId": session_id }),
254 )
255 .await;
256 })
257 .await;
258 registry.foreground_delete(browser_name, target_id)?;
259 Ok(())
260}
261
262async fn wait_for_exit(
264 events: &mut broadcast::Receiver<CdpEvent>,
265 target_id: &str,
266 session_id: &str,
267 timeout: Duration,
268) -> &'static str {
269 let stop = stop_signal();
270 tokio::pin!(stop);
271 let expiry = tokio::time::sleep(timeout);
273 tokio::pin!(expiry);
274 loop {
275 tokio::select! {
276 _ = &mut stop => return "stop requested",
277 _ = &mut expiry => return "timeout reached",
278 ev = events.recv() => match ev {
279 Ok(ev) => {
280 let ours_session = ev.session_id.as_deref() == Some(session_id);
281 match ev.method.as_str() {
282 "Target.targetDestroyed" | "Target.targetCrashed"
283 if ev.params["targetId"].as_str() == Some(target_id) =>
284 {
285 return "tab closed";
286 }
287 "Target.detachedFromTarget"
288 if ev.params["sessionId"].as_str() == Some(session_id) =>
289 {
290 return "session detached";
291 }
292 "Inspector.detached" | "Inspector.targetCrashed" if ours_session => {
293 return "tab detached";
294 }
295 _ => {}
296 }
297 }
298 Err(broadcast::error::RecvError::Lagged(_)) => {}
299 Err(broadcast::error::RecvError::Closed) => return "browser connection closed",
300 },
301 }
302 }
303}
304
305#[cfg(unix)]
306async fn stop_signal() {
307 use tokio::signal::unix::{signal, SignalKind};
308 let mut term = match signal(SignalKind::terminate()) {
309 Ok(s) => s,
310 Err(_) => {
311 let _ = tokio::signal::ctrl_c().await;
312 return;
313 }
314 };
315 tokio::select! {
316 _ = term.recv() => {}
317 _ = tokio::signal::ctrl_c() => {}
318 }
319}
320
321#[cfg(not(unix))]
322async fn stop_signal() {
323 let _ = tokio::signal::ctrl_c().await;
324}