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 return Ok((pid, true));
140 }
141 }
142 if let Some(status) = child.try_wait()? {
143 bail!(
144 "foreground holder exited before attaching ({status}); is the tab still open and the browser a Chromium?"
145 );
146 }
147 if std::time::Instant::now() >= deadline {
148 let _ = child.kill();
149 bail!("foreground holder did not attach within {SPAWN_WAIT:?}");
150 }
151 std::thread::sleep(SPAWN_POLL);
152 }
153}
154
155pub fn stop_holder(registry: &Registry, browser_name: &str, target_id: &str) -> Result<bool> {
157 let Some(row) = registry.foreground_get(browser_name, target_id)? else {
158 return Ok(false);
159 };
160 terminate(row.pid);
161 let deadline = std::time::Instant::now() + Duration::from_secs(3);
164 while crate::registry::pid_alive(row.pid) && std::time::Instant::now() < deadline {
165 std::thread::sleep(SPAWN_POLL);
166 }
167 registry.foreground_delete(browser_name, target_id)?;
168 Ok(true)
169}
170
171pub fn stop_all(registry: &Registry, browser_name: &str) -> Result<usize> {
173 let rows = registry.foreground_list(browser_name)?;
174 let mut n = 0;
175 for r in rows {
176 if stop_holder(registry, browser_name, &r.target_id)? {
177 n += 1;
178 }
179 }
180 Ok(n)
181}
182
183fn terminate(pid: u32) {
187 #[cfg(unix)]
188 {
189 unsafe {
191 libc::kill(pid as i32, libc::SIGTERM);
192 }
193 }
194 #[cfg(not(unix))]
195 {
196 let mut sys = sysinfo::System::new();
197 let p = sysinfo::Pid::from_u32(pid);
198 sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[p]), true);
199 if let Some(proc_) = sys.process(p) {
200 proc_.kill();
201 }
202 }
203}
204
205pub async fn hold(browser_name: &str, target_id: &str, timeout: Duration) -> Result<()> {
208 let registry = Registry::open()?;
209 let row = registry
210 .get_by_name(browser_name)?
211 .ok_or_else(|| anyhow!("no registered browser named {browser_name}"))?;
212 if row.engine != Engine::Cdp {
213 bail!("foreground emulation is Chromium-only (Firefox has no BiDi equivalent)");
214 }
215 let TabBackend::Cdp(client) = open_backend(&row.endpoint, Engine::Cdp).await? else {
216 bail!("expected a CDP backend");
217 };
218 let mut events = client.subscribe();
219 let _ = client
221 .send("Target.setDiscoverTargets", json!({ "discover": true }))
222 .await;
223 let session_id = client.attach_to_target(target_id).await?;
224 let _ = client
225 .send_with_session("Inspector.enable", json!({}), Some(&session_id))
226 .await;
227 apply(&client, &session_id, true).await?;
228 let expires_at = crate::registry::now_epoch_s() + timeout.as_secs() as i64;
229 registry.foreground_upsert(browser_name, target_id, std::process::id(), expires_at)?;
230 tracing::info!(
231 browser = browser_name,
232 target = target_id,
233 ?timeout,
234 "foreground emulation held"
235 );
236
237 let reason = wait_for_exit(&mut events, target_id, &session_id, timeout).await;
238 tracing::info!(browser = browser_name, target = target_id, %reason, "foreground holder exiting");
239 let _ = tokio::time::timeout(Duration::from_secs(2), async {
240 let _ = apply(&client, &session_id, false).await;
241 let _ = client
242 .send(
243 "Target.detachFromTarget",
244 json!({ "sessionId": session_id }),
245 )
246 .await;
247 })
248 .await;
249 registry.foreground_delete(browser_name, target_id)?;
250 Ok(())
251}
252
253async fn wait_for_exit(
255 events: &mut broadcast::Receiver<CdpEvent>,
256 target_id: &str,
257 session_id: &str,
258 timeout: Duration,
259) -> &'static str {
260 let stop = stop_signal();
261 tokio::pin!(stop);
262 let expiry = tokio::time::sleep(timeout);
264 tokio::pin!(expiry);
265 loop {
266 tokio::select! {
267 _ = &mut stop => return "stop requested",
268 _ = &mut expiry => return "timeout reached",
269 ev = events.recv() => match ev {
270 Ok(ev) => {
271 let ours_session = ev.session_id.as_deref() == Some(session_id);
272 match ev.method.as_str() {
273 "Target.targetDestroyed" | "Target.targetCrashed"
274 if ev.params["targetId"].as_str() == Some(target_id) =>
275 {
276 return "tab closed";
277 }
278 "Target.detachedFromTarget"
279 if ev.params["sessionId"].as_str() == Some(session_id) =>
280 {
281 return "session detached";
282 }
283 "Inspector.detached" | "Inspector.targetCrashed" if ours_session => {
284 return "tab detached";
285 }
286 _ => {}
287 }
288 }
289 Err(broadcast::error::RecvError::Lagged(_)) => {}
290 Err(broadcast::error::RecvError::Closed) => return "browser connection closed",
291 },
292 }
293 }
294}
295
296#[cfg(unix)]
297async fn stop_signal() {
298 use tokio::signal::unix::{signal, SignalKind};
299 let mut term = match signal(SignalKind::terminate()) {
300 Ok(s) => s,
301 Err(_) => {
302 let _ = tokio::signal::ctrl_c().await;
303 return;
304 }
305 };
306 tokio::select! {
307 _ = term.recv() => {}
308 _ = tokio::signal::ctrl_c() => {}
309 }
310}
311
312#[cfg(not(unix))]
313async fn stop_signal() {
314 let _ = tokio::signal::ctrl_c().await;
315}