Skip to main content

browser_control/session/
foreground.rs

1//! Foreground emulation for background tabs (ADR-004).
2//!
3//! Chromium treats a tab in a minimized window, or any tab while the
4//! display is locked, as hidden: `requestAnimationFrame` never fires,
5//! timers are throttled, `document.visibilityState` is `hidden` and
6//! `document.hasFocus()` is false. `Emulation.setFocusEmulationEnabled`
7//! flips all of that, but only for as long as the CDP session that enabled
8//! it stays attached.
9//!
10//! So both the CLI (`browser-control tab foreground <browser>/<tab> on`) and
11//! the MCP tool (`browser_tab_foreground`) use the same thing: a small
12//! detached **holder process** (`browser-control tab foreground-hold`, a
13//! hidden subcommand) that attaches one session, enables the emulation, and
14//! blocks until it is stopped, the tab goes away, or the browser exits. Its
15//! PID is recorded in the registry so `off` can stop it and tab listings can
16//! show the flag from either surface. The holder is push-only: it waits on
17//! CDP events and a termination signal; nothing polls or wakes on a timer.
18//! This is the documented exception to the daemonless rule in ADR-002.
19
20use 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
32/// How long `spawn_holder` waits for the child to register itself.
33const SPAWN_WAIT: Duration = Duration::from_secs(8);
34const SPAWN_POLL: Duration = Duration::from_millis(50);
35
36/// Make Chromium treat the tab as focused and visible (or stop doing so).
37/// `setFocusEmulationEnabled` is what flips `document.visibilityState`,
38/// `document.hasFocus()`, `requestAnimationFrame`, and timer throttling for
39/// a minimized window or a locked display; `setIdleOverride` additionally
40/// answers the Idle Detection API with "active, unlocked".
41pub 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
68/// Whether a live holder exists for the tab.
69pub 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
77/// Target ids under foreground emulation for a browser.
78pub 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
86/// Default lifetime of a holder. Agents forget to turn things off; an hour
87/// covers a long debugging session without keeping a game at 60 fps in the
88/// background forever.
89pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60 * 60);
90
91/// Start a detached holder for the tab that expires after `timeout`, or
92/// return the existing one. Returns `(pid, created)`.
93pub 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        // Own process group: survives the parent's shell job control and
123        // Ctrl-C in the terminal that started it.
124        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
155/// Stop the holder for the tab, if any. Returns whether one was running.
156pub 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    // The holder deletes its own row on a clean exit; make sure it is gone
162    // even if it was killed hard.
163    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
171/// Stop every holder on a browser. Returns how many were running.
172pub 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
183/// Ask a holder to exit. SIGTERM on Unix so it can disable the emulation
184/// and detach cleanly; `TerminateProcess` on Windows (the session drop
185/// reverts the emulation anyway).
186fn terminate(pid: u32) {
187    #[cfg(unix)]
188    {
189        // SAFETY: plain syscall with a pid we recorded ourselves.
190        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
205/// Body of the hidden `tab foreground-hold` subcommand: attach, emulate,
206/// record, and block until told to stop or the tab/browser goes away.
207pub 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    // Browser-level target events tell us when our tab is closed.
220    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
253/// Block until a stop signal or a CDP event says the tab or browser is gone.
254async 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    // One deadline, requested by the user: the holder's own expiry.
263    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}