Merge branch 'feature-browser-upgrade' into main

This commit is contained in:
James Peter
2025-08-12 22:52:03 +10:00
+534 -174
View File
@@ -1,6 +1,7 @@
use crate::{BrowserError, Result, config::BrowserConfig, page::Page};
use chromiumoxide::cdp::browser_protocol::{emulation, network};
use chromiumoxide::{Browser, BrowserConfig as CdpConfig};
use chromiumoxide::browser::HeadlessMode;
use futures::StreamExt;
use reqwest::Client;
use serde::Deserialize;
@@ -17,9 +18,20 @@ struct JsonVersion {
async fn discover_ws_via_port(port: u16) -> Result<String> {
let url = format!("http://127.0.0.1:{}/json/version", port);
let resp = Client::new().get(&url).send().await.map_err(|e| {
debug!("Requesting Chrome version info from: {}", url);
let client_start = tokio::time::Instant::now();
let client = Client::builder()
.timeout(Duration::from_secs(5)) // Add timeout to prevent hanging
.build()
.map_err(|e| BrowserError::CdpError(format!("Failed to build HTTP client: {}", e)))?;
debug!("HTTP client created in {:?}", client_start.elapsed());
let req_start = tokio::time::Instant::now();
let resp = client.get(&url).send().await.map_err(|e| {
BrowserError::CdpError(format!("Failed to connect to Chrome debug port: {}", e))
})?;
debug!("HTTP request completed in {:?}, status: {}", req_start.elapsed(), resp.status());
if !resp.status().is_success() {
return Err(BrowserError::CdpError(format!(
@@ -28,9 +40,11 @@ async fn discover_ws_via_port(port: u16) -> Result<String> {
)));
}
let parse_start = tokio::time::Instant::now();
let body: JsonVersion = resp.json().await.map_err(|e| {
BrowserError::CdpError(format!("Failed to parse Chrome debug response: {}", e))
})?;
debug!("Response parsed in {:?}", parse_start.elapsed());
Ok(body.web_socket_debugger_url)
}
@@ -77,24 +91,49 @@ async fn scan_for_chrome_debug_port() -> Option<u16> {
found_ports
);
// Test each found port to see if it's accessible
// Test each found port to see if it's accessible (test in parallel for speed)
if found_ports.is_empty() {
return None;
}
debug!("Testing {} port(s) for accessibility...", found_ports.len());
let test_start = tokio::time::Instant::now();
// Create futures for testing all ports in parallel
let mut port_tests = Vec::new();
for port in found_ports {
let url = format!("http://127.0.0.1:{}/json/version", port);
let client = Client::builder()
.timeout(Duration::from_millis(500))
.build()
.ok()?;
let test_future = async move {
let url = format!("http://127.0.0.1:{}/json/version", port);
let client = Client::builder()
.timeout(Duration::from_millis(200)) // Shorter timeout for parallel tests
.build()
.ok()?;
if let Ok(resp) = client.get(&url).send().await {
if resp.status().is_success() {
info!("Verified Chrome debug port at {} is accessible", port);
return Some(port);
} else {
debug!("Chrome port {} returned status: {}", port, resp.status());
match client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => {
debug!("Chrome port {} is accessible", port);
Some(port)
}
Ok(resp) => {
debug!("Chrome port {} returned status: {}", port, resp.status());
None
}
Err(_) => {
debug!("Could not connect to Chrome port {}", port);
None
}
}
} else {
debug!("Could not connect to Chrome port {}", port);
}
};
port_tests.push(test_future);
}
// Test all ports in parallel and return the first accessible one
let results = futures::future::join_all(port_tests).await;
debug!("Port accessibility tests completed in {:?}", test_start.elapsed());
for port in results.into_iter().flatten() {
info!("Verified Chrome debug port at {} is accessible", port);
return Some(port);
}
warn!("No accessible Chrome debug ports found");
@@ -105,11 +144,16 @@ pub struct BrowserManager {
pub config: Arc<RwLock<BrowserConfig>>,
browser: Arc<Mutex<Option<Browser>>>,
page: Arc<Mutex<Option<Arc<Page>>>>,
// Dedicated background page for screenshots to prevent focus stealing
background_page: Arc<Mutex<Option<Arc<Page>>>>,
last_activity: Arc<Mutex<Instant>>,
idle_monitor_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
event_task: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
assets: Arc<Mutex<Option<Arc<crate::assets::AssetManager>>>>,
user_data_dir: Arc<Mutex<Option<String>>>,
cleanup_profile_on_drop: Arc<Mutex<bool>>,
navigation_callback: Arc<tokio::sync::RwLock<Option<Box<dyn Fn(String) + Send + Sync>>>>,
navigation_monitor_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
}
impl BrowserManager {
@@ -118,11 +162,15 @@ impl BrowserManager {
config: Arc::new(RwLock::new(config)),
browser: Arc::new(Mutex::new(None)),
page: Arc::new(Mutex::new(None)),
background_page: Arc::new(Mutex::new(None)),
last_activity: Arc::new(Mutex::new(Instant::now())),
idle_monitor_handle: Arc::new(Mutex::new(None)),
event_task: Arc::new(Mutex::new(None)),
assets: Arc::new(Mutex::new(None)),
user_data_dir: Arc::new(Mutex::new(None)),
cleanup_profile_on_drop: Arc::new(Mutex::new(false)),
navigation_callback: Arc::new(tokio::sync::RwLock::new(None)),
navigation_monitor_handle: Arc::new(Mutex::new(None)),
}
}
@@ -138,140 +186,131 @@ impl BrowserManager {
if let Some(ws) = config.connect_ws.clone() {
info!("Connecting to Chrome via WebSocket: {}", ws);
let (browser, mut handler) = Browser::connect(ws).await?;
tokio::spawn(async move { while let Some(_evt) = handler.next().await {} });
// Don't fetch targets immediately - can interfere with screenshot capture
let task = tokio::spawn(async move { while let Some(_evt) = handler.next().await {} });
*self.event_task.lock().await = Some(task);
*browser_guard = Some(browser);
*self.cleanup_profile_on_drop.lock().await = false;
self.start_idle_monitor().await;
self.update_activity().await;
return Ok(());
}
if let Some(port) = config.connect_port {
// If port is 0, auto-scan for Chrome debug ports
let actual_port = if port == 0 {
info!("Auto-scanning for Chrome debug ports...");
match scan_for_chrome_debug_port().await {
Some(found_port) => {
info!("Auto-detected Chrome on port {}", found_port);
found_port
}
None => {
warn!(
"No Chrome debug ports found during auto-scan. Will launch new instance."
);
0 // Signal to fall through to launch
}
}
let start = tokio::time::Instant::now();
let result = scan_for_chrome_debug_port().await.unwrap_or(0);
info!("Auto-scan completed in {:?}, found port: {}", start.elapsed(), result);
result
} else {
info!("Using specified Chrome debug port: {}", port);
port
};
if actual_port > 0 {
info!("Discovering Chrome via debug port: {}", actual_port);
info!("Step 1: Discovering Chrome WebSocket URL via port {}...", actual_port);
let discover_start = tokio::time::Instant::now();
match discover_ws_via_port(actual_port).await {
Ok(ws) => {
info!("Connecting to Chrome via discovered WebSocket: {}", ws);
info!("Step 2: WebSocket URL discovered in {:?}: {}", discover_start.elapsed(), ws);
info!("Step 3: Connecting to Chrome via WebSocket...");
let connect_start = tokio::time::Instant::now();
let (browser, mut handler) = Browser::connect(ws).await?;
tokio::spawn(async move { while let Some(_evt) = handler.next().await {} });
info!("Step 4: Connected to Chrome in {:?}", connect_start.elapsed());
// Don't fetch targets immediately - can interfere with screenshot capture
let task = tokio::spawn(async move { while let Some(_evt) = handler.next().await {} });
*self.event_task.lock().await = Some(task);
*browser_guard = Some(browser);
*self.cleanup_profile_on_drop.lock().await = false;
info!("Step 5: Starting idle monitor...");
self.start_idle_monitor().await;
self.update_activity().await;
info!("Step 6: Chrome connection complete!");
return Ok(());
}
Err(e) => {
warn!(
"Failed to connect to Chrome on port {}: {}. Will launch new instance.",
actual_port, e
);
// Fall through to launch
warn!("Failed to discover WebSocket after {:?}: {}. Will launch new instance.", discover_start.elapsed(), e);
}
}
}
}
// 2) Otherwise: launch a browser
// 2) Launch a browser
info!("Launching new browser instance");
let mut builder = CdpConfig::builder();
// Use persistent profile if specified, otherwise temp
// Profile dir
let user_data_path = if let Some(dir) = &config.user_data_dir {
builder = builder.user_data_dir(dir.clone());
dir.to_string_lossy().to_string()
} else {
// Create temp profile
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis();
let temp_path = format!("/tmp/coder-browser-{}-{}", std::process::id(), timestamp);
// Ensure the directory doesn't exist before starting
if tokio::fs::metadata(&temp_path).await.is_ok() {
if let Err(e) = tokio::fs::remove_dir_all(&temp_path).await {
warn!(
"Failed to cleanup existing browser directory {}: {}",
temp_path, e
);
}
let _ = tokio::fs::remove_dir_all(&temp_path).await;
}
builder = builder.user_data_dir(&temp_path);
temp_path
};
// Configure viewport
builder = builder.window_size(config.viewport.width, config.viewport.height);
// Set headless mode based on config
// Set headless mode based on config (keep original approach for stability)
if config.headless {
builder = builder.headless_mode(chromiumoxide::browser::HeadlessMode::New);
builder = builder.headless_mode(HeadlessMode::New);
}
// Add less automation-screamy flags
// Configure viewport (revert to original approach for screenshot stability)
builder = builder.window_size(config.viewport.width, config.viewport.height);
// Add browser launch flags (keep minimal set for screenshot functionality)
builder = builder
.arg("--disable-blink-features=AutomationControlled")
.arg("--disable-features=VizDisplayCompositor");
let browser_config = builder
.build()
.map_err(|e| BrowserError::CdpError(e.to_string()))?;
.arg("--no-first-run")
.arg("--no-default-browser-check");
let browser_config = builder.build().map_err(|e| BrowserError::CdpError(e.to_string()))?;
let (browser, mut handler) = Browser::launch(browser_config).await?;
// Optionally: browser.fetch_targets().await.ok();
tokio::spawn(async move {
let task = tokio::spawn(async move {
while let Some(event) = handler.next().await {
debug!("Browser event: {:?}", event);
}
});
*self.event_task.lock().await = Some(task);
*browser_guard = Some(browser);
*self.user_data_dir.lock().await = Some(user_data_path.clone());
// Store the user data directory path for cleanup
{
let mut user_data_guard = self.user_data_dir.lock().await;
*user_data_guard = Some(user_data_path.clone());
}
// Determine if we should cleanup on drop
let should_cleanup = config.user_data_dir.is_none() || !config.persist_profile;
*self.cleanup_profile_on_drop.lock().await = should_cleanup;
self.start_idle_monitor().await;
self.update_activity().await;
Ok(())
}
pub async fn stop(&self) -> Result<()> {
self.stop_idle_monitor().await;
// stop event handler task cleanly
if let Some(task) = self.event_task.lock().await.take() {
task.abort();
}
self.stop_navigation_monitor().await;
let mut page_guard = self.page.lock().await;
*page_guard = None;
// Also cleanup the background page
let mut background_page_guard = self.background_page.lock().await;
*background_page_guard = None;
let config = self.config.read().await;
let is_external_chrome = config.connect_port.is_some() || config.connect_ws.is_some();
drop(config);
@@ -287,29 +326,15 @@ impl BrowserManager {
}
}
// Only cleanup user data directory if we should
// When cleaning profiles, respect the flag everywhere:
let should_cleanup = *self.cleanup_profile_on_drop.lock().await;
if should_cleanup {
let mut user_data_guard = self.user_data_dir.lock().await;
if let Some(user_data_path) = user_data_guard.take() {
// Give Chrome a moment to fully release the profile
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
if let Err(e) = tokio::fs::remove_dir_all(&user_data_path).await {
warn!(
"Failed to cleanup browser user data directory {}: {}",
user_data_path, e
);
// Try a more aggressive cleanup on macOS
#[cfg(target_os = "macos")]
{
let _ = tokio::process::Command::new("rm")
.arg("-rf")
.arg(&user_data_path)
.output()
.await;
}
}
let _ = tokio::fs::remove_dir_all(&user_data_path).await;
#[cfg(target_os = "macos")]
{ let _ = tokio::process::Command::new("rm").arg("-rf").arg(&user_data_path).output().await; }
}
}
@@ -411,6 +436,9 @@ impl BrowserManager {
let page = Arc::new(Page::new(cdp_page, config.clone()));
*page_guard = Some(Arc::clone(&page));
// Start navigation monitoring for this page
self.start_navigation_monitor(Arc::clone(&page)).await;
Ok(page)
}
@@ -423,6 +451,159 @@ impl BrowserManager {
Ok(())
}
/// Get or create a dedicated background page for screenshots to prevent focus stealing
pub async fn get_or_create_background_page(&self) -> Result<Arc<Page>> {
self.ensure_browser().await?;
self.update_activity().await;
let mut background_page_guard = self.background_page.lock().await;
if let Some(page) = background_page_guard.as_ref() {
return Ok(Arc::clone(page));
}
let browser_guard = self.browser.lock().await;
let browser = browser_guard.as_ref().ok_or(BrowserError::NotInitialized)?;
let config = self.config.read().await;
// For external Chrome connections, create a minimal new tab for screenshots
let cdp_page = if config.connect_port.is_some() || config.connect_ws.is_some() {
info!("Creating screenshot tab for focus-free captures");
// Try background tab creation first, fallback to regular tab if it fails
let background_page = {
// Attempt 1: Try creating true background tab
let background_attempt = async {
use chromiumoxide::cdp::browser_protocol::target::CreateTargetParams;
let create_params = CreateTargetParams::builder()
.url("about:blank")
.background(true)
.build()
.map_err(|e| BrowserError::CdpError(e))?;
let result = browser.execute(create_params).await?;
browser.get_page(result.target_id.clone()).await
.map_err(|e| BrowserError::CdpError(e.to_string()))
}.await;
match background_attempt {
Ok(page) => {
info!("Successfully created true background tab");
page
}
Err(e) => {
warn!("Background tab creation failed ({}), using regular tab with enhanced focus prevention", e);
// Fallback: Create a regular new tab but with immediate focus prevention
let new_page = browser.new_page("about:blank").await?;
// Immediately minimize focus impact by making it as hidden as possible
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
new_page
}
}
};
// Apply aggressive focus prevention to any tab we created
{
use chromiumoxide::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocumentParams;
// Enhanced focus prevention script
let focus_prevention_script = r#"
(function() {
console.log('Initializing focus prevention for screenshot tab');
// Completely override focus-related methods
const noop = function() { console.log('Focus attempt blocked'); };
window.focus = noop;
if (document.body) document.body.focus = noop;
// Override visibility and focus state permanently
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
writable: false,
configurable: false
});
Object.defineProperty(document, 'hasFocus', {
value: () => false,
writable: false,
configurable: false
});
// Block ALL focus-related events at capture phase
const focusEvents = ['focus', 'focusin', 'focusout', 'blur', 'activate', 'show'];
focusEvents.forEach(eventType => {
window.addEventListener(eventType, function(e) {
e.stopImmediatePropagation();
e.preventDefault();
console.log('Blocked focus event:', eventType);
}, true);
document.addEventListener(eventType, function(e) {
e.stopImmediatePropagation();
e.preventDefault();
}, true);
});
// Override window management methods
window.blur = noop;
window.moveBy = noop;
window.moveTo = noop;
window.resizeBy = noop;
window.resizeTo = noop;
// Block any attempt to make this tab visible
if (window.chrome && window.chrome.tabs) {
window.chrome.tabs.update = noop;
window.chrome.tabs.highlight = noop;
}
console.log('Screenshot tab focus prevention fully activated');
})();
"#;
let script_params = AddScriptToEvaluateOnNewDocumentParams::builder()
.source(focus_prevention_script)
.build()
.map_err(|e| BrowserError::CdpError(e.to_string()))?;
if let Err(e) = background_page.execute(script_params).await {
warn!("Focus prevention script injection failed: {}", e);
}
}
background_page
} else {
// For launched Chrome, create a regular new page (it's already isolated)
browser.new_page("about:blank").await?
};
// Apply minimal page overrides for screenshots (no user agent spoofing needed)
{
use chromiumoxide::cdp::browser_protocol::{network, emulation};
// Enable Network domain for any required network operations
cdp_page.execute(network::EnableParams::default()).await?;
// Set viewport to match config
let viewport_params = emulation::SetDeviceMetricsOverrideParams::builder()
.width(config.viewport.width as i64)
.height(config.viewport.height as i64)
.device_scale_factor(config.viewport.device_scale_factor)
.mobile(config.viewport.mobile)
.build()
.map_err(|e| BrowserError::CdpError(e.to_string()))?;
cdp_page.execute(viewport_params).await?;
}
let background_page = Arc::new(Page::new(cdp_page, config.clone()));
*background_page_guard = Some(Arc::clone(&background_page));
info!("Background screenshot tab created successfully");
Ok(background_page)
}
pub async fn is_enabled(&self) -> bool {
self.config.read().await.enabled
}
@@ -486,81 +667,33 @@ impl BrowserManager {
pub async fn apply_page_overrides(&self, page: &chromiumoxide::Page) -> Result<()> {
let config = self.config.read().await;
// Enable Network domain before setting headers
// Network + UA
page.execute(network::EnableParams::default()).await?;
// SetUserAgentOverrideParams requires user_agent to be set
// Only call it if we have a user_agent (accept_language is optional)
if let Some(ua) = &config.user_agent {
let mut params_builder = network::SetUserAgentOverrideParams::builder().user_agent(ua);
if let Some(al) = &config.accept_language {
params_builder = params_builder.accept_language(al);
}
let params = params_builder
let mut b = network::SetUserAgentOverrideParams::builder().user_agent(ua);
if let Some(al) = &config.accept_language { b = b.accept_language(al); }
page.execute(b.build().map_err(BrowserError::CdpError)?).await?;
} else if let Some(al) = &config.accept_language {
let mut headers_map = std::collections::HashMap::new();
headers_map.insert("Accept-Language".to_string(), serde_json::Value::String(al.clone()));
let headers = network::Headers::new(serde_json::Value::Object(headers_map.into_iter().map(|(k, v)| (k, v)).collect()));
let p = network::SetExtraHttpHeadersParams::builder()
.headers(headers)
.build()
.map_err(|e| BrowserError::CdpError(e))?;
page.execute(params).await?;
.map_err(BrowserError::CdpError)?;
page.execute(p).await?;
}
// Timezone / locale
if let Some(tz) = &config.timezone {
page.execute(emulation::SetTimezoneOverrideParams {
timezone_id: tz.clone(),
})
.await?;
page.execute(emulation::SetTimezoneOverrideParams { timezone_id: tz.clone() }).await?;
}
if let Some(locale) = &config.locale {
let params = emulation::SetLocaleOverrideParams::builder()
.locale(locale)
.build();
page.execute(params).await?;
}
let params = emulation::SetDeviceMetricsOverrideParams::builder()
.width(config.viewport.width as i64)
.height(config.viewport.height as i64)
.device_scale_factor(config.viewport.device_scale_factor)
.mobile(config.viewport.mobile)
.build()
.map_err(|e| BrowserError::CdpError(e))?;
page.execute(params).await?;
// For external Chrome connections, try to prevent focus stealing
let is_external_chrome = config.connect_port.is_some() || config.connect_ws.is_some();
if is_external_chrome && config.prevent_focus_steal {
debug!("Configuring external Chrome to reduce focus stealing");
// Try to inject JavaScript that prevents focus events from bubbling
// This helps reduce the likelihood of the browser taking focus
let focus_prevention_script = r#"
try {
// Override focus methods to be less aggressive
const originalFocus = window.focus;
window.focus = function() {
// Only focus if document is already visible to avoid stealing focus
if (document.visibilityState === 'visible') {
originalFocus.call(this);
}
};
// Prevent focus events from bubbling up
document.addEventListener('focus', function(e) {
e.stopImmediatePropagation();
}, true);
console.log('Focus prevention installed');
} catch (e) {
console.warn('Could not install focus prevention:', e);
}
"#;
if let Err(e) = page.evaluate(focus_prevention_script).await {
debug!("Could not install focus prevention script: {}", e);
}
let p = emulation::SetLocaleOverrideParams::builder().locale(locale).build();
page.execute(p).await?;
}
// NO viewport/DPR here.
Ok(())
}
@@ -668,9 +801,9 @@ impl BrowserManager {
let config = self.config.read().await;
let idle_timeout = Duration::from_millis(config.idle_timeout_ms);
let is_external_chrome = config.connect_port.is_some() || config.connect_ws.is_some();
let should_cleanup = *self.cleanup_profile_on_drop.lock().await; // <-- respect this
drop(config);
// Don't start idle monitor for external Chrome connections
if is_external_chrome {
info!("Skipping idle monitor for external Chrome connection");
return;
@@ -683,7 +816,6 @@ impl BrowserManager {
let handle = tokio::spawn(async move {
loop {
sleep(Duration::from_secs(10)).await;
let last = *last_activity.lock().await;
if last.elapsed() > idle_timeout {
warn!("Browser idle timeout reached, closing");
@@ -691,25 +823,17 @@ impl BrowserManager {
if let Some(mut browser) = browser_guard.take() {
let _ = browser.close().await;
}
// Cleanup user data directory on idle timeout
let mut user_data_guard = user_data_dir.lock().await;
if let Some(user_data_path) = user_data_guard.take() {
if let Err(e) = tokio::fs::remove_dir_all(&user_data_path).await {
warn!(
"Failed to cleanup browser user data directory {}: {}",
user_data_path, e
);
if should_cleanup {
if let Some(user_data_path) = user_data_dir.lock().await.take() {
let _ = tokio::fs::remove_dir_all(&user_data_path).await;
}
}
break;
}
}
});
let mut handle_guard = self.idle_monitor_handle.lock().await;
*handle_guard = Some(handle);
*self.idle_monitor_handle.lock().await = Some(handle);
}
async fn stop_idle_monitor(&self) {
@@ -723,8 +847,16 @@ impl BrowserManager {
// Get or create page
let page = self.get_or_create_page().await?;
info!("Navigating to URL: {}", url);
let config = self.config.read().await;
let result = page.goto(url, Some(config.wait.clone())).await?;
info!("Navigation complete to: {}", result.url);
// Manually trigger navigation callback for immediate response
if let Some(ref callback) = *self.navigation_callback.read().await {
debug!("Manually triggering navigation callback after goto");
callback(result.url.clone());
}
self.update_activity().await;
Ok(result)
@@ -743,7 +875,85 @@ impl BrowserManager {
}
async fn capture_screenshot_internal(&self) -> Result<(Vec<std::path::PathBuf>, String)> {
// Ensure we have a browser and page
// Always use the regular approach - capture from the actual page
// The background page strategy was fundamentally broken
self.capture_screenshot_regular().await
}
/// Capture screenshot using background tab strategy (external Chrome)
async fn capture_screenshot_background_strategy(&self) -> Result<(Vec<std::path::PathBuf>, String)> {
// First get the main page to capture the current URL (no interaction)
let main_page = self.get_or_create_page().await?;
// Then use background page for the actual screenshot to avoid focus issues
let screenshot_page = self.get_or_create_background_page().await?;
// Note: Focus preservation is handled at the Chrome flag and JavaScript level
// Initialize assets manager if needed
let mut assets_guard = self.assets.lock().await;
if assets_guard.is_none() {
*assets_guard = Some(Arc::new(crate::assets::AssetManager::new().await?));
}
let assets = assets_guard.as_ref().unwrap().clone();
drop(assets_guard);
// Get current config
let config = self.config.read().await;
// Determine screenshot mode
let mode = if config.fullpage {
crate::page::ScreenshotMode::FullPage {
segments_max: Some(config.segments_max),
}
} else {
crate::page::ScreenshotMode::Viewport
};
// Get current URL from the main page (user's active tab)
let current_url = main_page
.get_current_url()
.await
.unwrap_or_else(|_| "about:blank".to_string());
// Navigate background page to the same URL if it's not about:blank
if current_url != "about:blank" && !current_url.is_empty() {
info!("Navigating background tab to {} for screenshot", current_url);
// Use a shorter wait strategy for background navigation to reduce delay
let quick_wait = crate::config::WaitStrategy::Delay { delay_ms: 1000 };
screenshot_page.goto(&current_url, Some(quick_wait)).await?;
// Give the page a moment to settle
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
// Capture screenshots from the background page (no focus stealing!)
info!("Taking screenshot from background tab to preserve focus");
let screenshots = screenshot_page.screenshot(mode).await?;
// Store screenshots and get paths
let mut paths = Vec::new();
for screenshot in screenshots {
let image_ref = assets
.store_screenshot(
&screenshot.data,
screenshot.format,
screenshot.width,
screenshot.height,
300000, // 5 minute TTL
)
.await?;
paths.push(std::path::PathBuf::from(image_ref.path));
}
// Focus preservation is handled by the background tab strategy and Chrome flags
self.update_activity().await;
Ok((paths, current_url))
}
/// Capture screenshot using regular strategy (launched Chrome)
async fn capture_screenshot_regular(&self) -> Result<(Vec<std::path::PathBuf>, String)> {
// For launched Chrome, use the regular approach since it's already isolated
let page = self.get_or_create_page().await?;
// Initialize assets manager if needed
@@ -766,7 +976,7 @@ impl BrowserManager {
crate::page::ScreenshotMode::Viewport
};
// Get current URL directly from the browser (not cached)
// Get current URL
let current_url = page
.get_current_url()
.await
@@ -822,6 +1032,156 @@ impl BrowserManager {
let page = self.get_or_create_page().await?;
page.execute_javascript(code).await
}
/// Set a callback to be called when navigation occurs
pub async fn set_navigation_callback<F>(&self, callback: F)
where
F: Fn(String) + Send + Sync + 'static,
{
let mut callback_guard = self.navigation_callback.write().await;
*callback_guard = Some(Box::new(callback));
}
/// Start monitoring for page navigation changes
async fn start_navigation_monitor(&self, page: Arc<Page>) {
// Stop any existing monitor
self.stop_navigation_monitor().await;
let navigation_callback = Arc::clone(&self.navigation_callback);
let page_weak = Arc::downgrade(&page);
let handle = tokio::spawn(async move {
let mut last_url = String::new();
let mut check_count = 0;
loop {
// Check if page is still alive
let page = match page_weak.upgrade() {
Some(p) => p,
None => {
debug!("Page dropped, stopping navigation monitor");
break;
}
};
// Get current URL
if let Ok(current_url) = page.get_current_url().await {
// Check if URL changed (ignore about:blank)
if current_url != last_url && current_url != "about:blank" {
info!("Navigation detected: {} -> {}",
if last_url.is_empty() { "initial" } else { &last_url },
current_url);
last_url = current_url.clone();
// Call the callback if set (immediate)
if let Some(ref callback) = *navigation_callback.read().await {
debug!("Triggering navigation callback for URL: {}", current_url);
callback(current_url.clone());
}
// Schedule a delayed callback for fully loaded page
let navigation_callback_delayed = Arc::clone(&navigation_callback);
let current_url_delayed = current_url.clone();
tokio::spawn(async move {
// Wait for page to fully load
tokio::time::sleep(tokio::time::Duration::from_millis(2000)).await;
// Call the callback again with a marker that it's fully loaded
if let Some(ref callback) = *navigation_callback_delayed.read().await {
info!("Page fully loaded callback for: {}", current_url_delayed);
callback(current_url_delayed);
}
});
}
}
// Also inject JavaScript to detect client-side navigation
if check_count % 10 == 0 {
// Every 10 checks, reinject the navigation detection script
let script = r#"
(function() {
if (!window.__coder_nav_monitor) {
window.__coder_nav_monitor = true;
let lastUrl = window.location.href;
// Monitor for pushState/replaceState
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function() {
originalPushState.apply(history, arguments);
window.__coder_url_changed = true;
};
history.replaceState = function() {
originalReplaceState.apply(history, arguments);
window.__coder_url_changed = true;
};
// Monitor popstate event
window.addEventListener('popstate', function() {
window.__coder_url_changed = true;
});
// Monitor for hash changes
window.addEventListener('hashchange', function() {
window.__coder_url_changed = true;
});
}
// Check if URL changed
const changed = window.__coder_url_changed || false;
window.__coder_url_changed = false;
return {
url: window.location.href,
changed: changed
};
})()
"#;
if let Ok(result) = page.execute_javascript(script).await {
if let Some(changed) = result.get("changed").and_then(|v| v.as_bool()) {
if changed {
if let Some(url) = result.get("url").and_then(|v| v.as_str()) {
info!("Client-side navigation detected: {}", url);
if let Some(ref callback) = *navigation_callback.read().await {
callback(url.to_string());
}
// Schedule a delayed callback for fully loaded page
let navigation_callback_delayed = Arc::clone(&navigation_callback);
let url_delayed = url.to_string();
tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(2000)).await;
if let Some(ref callback) = *navigation_callback_delayed.read().await {
info!("Page fully loaded callback for client-side nav: {}", url_delayed);
callback(url_delayed);
}
});
}
}
}
}
}
check_count += 1;
// Check every 500ms
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
});
let mut handle_guard = self.navigation_monitor_handle.lock().await;
*handle_guard = Some(handle);
}
/// Stop navigation monitoring
async fn stop_navigation_monitor(&self) {
let mut handle_guard = self.navigation_monitor_handle.lock().await;
if let Some(handle) = handle_guard.take() {
handle.abort();
}
}
}
#[derive(Debug, Clone, serde::Serialize)]