mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(tui/chrome): allow specifying host for external Chrome connection (dev containers)\n\n- Add to browser config and plumb through BrowserManager\n- Extend to accept , Host port not found: 3(NXDOMAIN), plain , or a full endpoint\n- Use host when discovering WS URL instead of always 127.0.0.1\n\nThis enables connecting from inside Docker/devcontainers to a Chrome running on the host (e.g., host.docker.internal:9222).\n\nCloses #39
This commit is contained in:
@@ -60,6 +60,10 @@ pub struct BrowserConfig {
|
||||
pub user_agent: Option<String>, // leave None to let Chrome decide
|
||||
|
||||
// --- Connection tuning (CDP attach) ---
|
||||
/// Optional host to use when connecting to an external Chrome via
|
||||
/// `connect_port`. Defaults to 127.0.0.1 when not set.
|
||||
#[serde(default)]
|
||||
pub connect_host: Option<String>,
|
||||
/// Per-attempt timeout for WS connect to Chrome (milliseconds)
|
||||
#[serde(default = "default_connect_attempt_timeout_ms")]
|
||||
pub connect_attempt_timeout_ms: u64,
|
||||
@@ -82,6 +86,7 @@ impl Default for BrowserConfig {
|
||||
headless: false, // Prefer headed for fewer false positives
|
||||
connect_ws: None,
|
||||
connect_port: None,
|
||||
connect_host: None,
|
||||
user_data_dir: None,
|
||||
persist_profile: default_persist_profile(),
|
||||
locale: Some("en-AU".into()),
|
||||
|
||||
@@ -28,8 +28,8 @@ struct JsonVersion {
|
||||
web_socket_debugger_url: String,
|
||||
}
|
||||
|
||||
async fn discover_ws_via_port(port: u16) -> Result<String> {
|
||||
let url = format!("http://127.0.0.1:{}/json/version", port);
|
||||
async fn discover_ws_via_host_port(host: &str, port: u16) -> Result<String> {
|
||||
let url = format!("http://{}:{}/json/version", host, port);
|
||||
debug!("Requesting Chrome version info from: {}", url);
|
||||
|
||||
let client_start = tokio::time::Instant::now();
|
||||
@@ -212,8 +212,8 @@ impl BrowserManager {
|
||||
|
||||
let config = self.config.read().await.clone();
|
||||
tracing::info!(
|
||||
"[cdp/bm] config: connect_port={:?}, connect_ws={:?}",
|
||||
config.connect_port, config.connect_ws
|
||||
"[cdp/bm] config: connect_host={:?}, connect_port={:?}, connect_ws={:?}",
|
||||
config.connect_host, config.connect_port, config.connect_ws
|
||||
);
|
||||
|
||||
// If a WebSocket is configured explicitly, try that first
|
||||
@@ -289,6 +289,7 @@ impl BrowserManager {
|
||||
|
||||
// Only try CDP connection via port, no fallback
|
||||
if let Some(port) = config.connect_port {
|
||||
let host = config.connect_host.as_deref().unwrap_or("127.0.0.1");
|
||||
let actual_port = if port == 0 {
|
||||
info!("Auto-scanning for Chrome debug ports...");
|
||||
let start = tokio::time::Instant::now();
|
||||
@@ -305,12 +306,12 @@ impl BrowserManager {
|
||||
};
|
||||
|
||||
if actual_port > 0 {
|
||||
info!("[cdp/bm] Discovering Chrome WebSocket URL via port {}...", actual_port);
|
||||
info!("[cdp/bm] Discovering Chrome WebSocket URL via {}:{}...", host, actual_port);
|
||||
// Retry discovery for up to 15s to allow a freshly launched Chrome to initialize
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
||||
let ws = loop {
|
||||
let discover_start = tokio::time::Instant::now();
|
||||
match discover_ws_via_port(actual_port).await {
|
||||
match discover_ws_via_host_port(host, actual_port).await {
|
||||
Ok(ws) => {
|
||||
info!("[cdp/bm] WS discovered in {:?}: {}", discover_start.elapsed(), ws);
|
||||
break ws;
|
||||
@@ -495,6 +496,7 @@ impl BrowserManager {
|
||||
}
|
||||
|
||||
if let Some(port) = config.connect_port {
|
||||
let host = config.connect_host.as_deref().unwrap_or("127.0.0.1");
|
||||
let actual_port = if port == 0 {
|
||||
info!("Auto-scanning for Chrome debug ports...");
|
||||
let start = tokio::time::Instant::now();
|
||||
@@ -511,13 +513,10 @@ impl BrowserManager {
|
||||
};
|
||||
|
||||
if actual_port > 0 {
|
||||
info!(
|
||||
"Step 1: Discovering Chrome WebSocket URL via port {}...",
|
||||
actual_port
|
||||
);
|
||||
info!("Step 1: Discovering Chrome WebSocket URL via {}:{}...", host, actual_port);
|
||||
let ws = loop {
|
||||
let discover_start = tokio::time::Instant::now();
|
||||
match discover_ws_via_port(actual_port).await {
|
||||
match discover_ws_via_host_port(host, actual_port).await {
|
||||
Ok(ws) => {
|
||||
info!(
|
||||
"Step 2: WebSocket URL discovered in {:?}: {}",
|
||||
|
||||
@@ -4308,17 +4308,18 @@ impl ChatWidget<'_> {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
|
||||
// Now try to connect using the shared CDP connection logic
|
||||
ChatWidget::connect_to_cdp_chrome(Some(port), latest_screenshot, app_event_tx).await;
|
||||
ChatWidget::connect_to_cdp_chrome(None, Some(port), latest_screenshot, app_event_tx).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Shared CDP connection logic used by both /chrome command and Chrome launch options
|
||||
async fn connect_to_cdp_chrome(
|
||||
host: Option<String>,
|
||||
port: Option<u16>,
|
||||
latest_screenshot: Arc<Mutex<Option<(PathBuf, String)>>>,
|
||||
app_event_tx: AppEventSender,
|
||||
) {
|
||||
tracing::info!("[cdp] connect_to_cdp_chrome() begin, port={:?}", port);
|
||||
tracing::info!("[cdp] connect_to_cdp_chrome() begin, host={:?}, port={:?}", host, port);
|
||||
let browser_manager = ChatWidget::get_browser_manager().await;
|
||||
browser_manager.set_enabled_sync(true);
|
||||
|
||||
@@ -4334,6 +4335,7 @@ impl ChatWidget<'_> {
|
||||
|
||||
if let Some(p) = port {
|
||||
config.connect_ws = None;
|
||||
config.connect_host = host.clone();
|
||||
config.connect_port = Some(p);
|
||||
} else {
|
||||
// Load persisted cache from disk (if any), then fall back to in-memory
|
||||
@@ -4350,9 +4352,11 @@ impl ChatWidget<'_> {
|
||||
} else if let Some(p) = cached_port_for_fallback {
|
||||
tracing::info!("[cdp] using cached Chrome debug port: {}", p);
|
||||
config.connect_ws = None;
|
||||
config.connect_host = host.clone();
|
||||
config.connect_port = Some(p);
|
||||
} else {
|
||||
config.connect_ws = None;
|
||||
config.connect_host = host.clone();
|
||||
config.connect_port = Some(0); // auto-detect
|
||||
}
|
||||
}
|
||||
@@ -5330,25 +5334,27 @@ impl ChatWidget<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_chrome_connection(&mut self, port: Option<u16>) {
|
||||
tracing::info!("[cdp] handle_chrome_connection begin, port={:?}", port);
|
||||
fn handle_chrome_connection(&mut self, host: Option<String>, port: Option<u16>) {
|
||||
tracing::info!("[cdp] handle_chrome_connection begin, host={:?}, port={:?}", host, port);
|
||||
self.browser_is_external = true;
|
||||
let latest_screenshot = self.latest_browser_screenshot.clone();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
let port_display = port.map_or("auto-detect".to_string(), |p| p.to_string());
|
||||
let host_display = host.clone().unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
|
||||
// Add status message to chat (use BackgroundEvent with header so it renders reliably)
|
||||
let status_msg = format!(
|
||||
"🔗 Connecting to Chrome DevTools Protocol (port: {})...",
|
||||
"🔗 Connecting to Chrome DevTools Protocol ({}:{})...",
|
||||
host_display,
|
||||
port_display
|
||||
);
|
||||
self.history_push(history_cell::new_background_event(status_msg));
|
||||
|
||||
// Connect in background with a single, unified flow (no double-connect)
|
||||
tokio::spawn(async move {
|
||||
tracing::info!("[cdp] connect task spawned, port={:?}", port);
|
||||
tracing::info!("[cdp] connect task spawned, host={:?}, port={:?}", host, port);
|
||||
// Unified connect flow; emits success/failure messages internally
|
||||
ChatWidget::connect_to_cdp_chrome(port, latest_screenshot.clone(), app_event_tx.clone()).await;
|
||||
ChatWidget::connect_to_cdp_chrome(host, port, latest_screenshot.clone(), app_event_tx.clone()).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5395,7 +5401,7 @@ impl ChatWidget<'_> {
|
||||
let handled_disconnect = rx.recv().unwrap_or(false);
|
||||
if !handled_disconnect {
|
||||
// Switch to external Chrome mode with default/auto-detected port
|
||||
self.handle_chrome_connection(None);
|
||||
self.handle_chrome_connection(None, None);
|
||||
} else {
|
||||
// We just disconnected; reflect in title immediately
|
||||
self.browser_is_external = false;
|
||||
@@ -5426,10 +5432,67 @@ impl ChatWidget<'_> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract port if provided (number as first argument)
|
||||
let port = parts[0].parse::<u16>().ok();
|
||||
tracing::info!("[cdp] parsed port: {:?}", port);
|
||||
self.handle_chrome_connection(port);
|
||||
// Accept several forms:
|
||||
// /chrome 9222
|
||||
// /chrome host:9222
|
||||
// /chrome host 9222
|
||||
// /chrome ws://host:9222/devtools/browser/<id>
|
||||
let mut host: Option<String> = None;
|
||||
let mut port: Option<u16> = None;
|
||||
let first = parts[0];
|
||||
|
||||
if let Some(ws) = first.strip_prefix("ws://").or_else(|| first.strip_prefix("wss://")) {
|
||||
// Full WS URL provided: set directly via config and return
|
||||
let ws_url = if first.starts_with("ws") { first.to_string() } else { format!("wss://{}", ws) };
|
||||
tracing::info!("[cdp] /chrome provided WS endpoint: {}", ws_url);
|
||||
// Configure and connect using WS
|
||||
self.browser_is_external = true;
|
||||
let latest_screenshot = self.latest_browser_screenshot.clone();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let bm = ChatWidget::get_browser_manager().await;
|
||||
{
|
||||
let mut cfg = bm.config.write().await;
|
||||
cfg.enabled = true;
|
||||
cfg.headless = false;
|
||||
cfg.persist_profile = true;
|
||||
cfg.connect_ws = Some(ws_url);
|
||||
cfg.connect_port = None;
|
||||
cfg.connect_host = None;
|
||||
}
|
||||
let _ = bm.connect_to_chrome_only().await;
|
||||
// Capture a first screenshot if possible
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
|
||||
match bm.capture_screenshot_with_url().await {
|
||||
Ok((paths, url)) => {
|
||||
if let Some(first_path) = paths.first() {
|
||||
if let Ok(mut latest) = latest_screenshot.lock() {
|
||||
*latest = Some((
|
||||
first_path.clone(),
|
||||
url.clone().unwrap_or_else(|| "Browser".to_string()),
|
||||
));
|
||||
}
|
||||
use codex_core::protocol::{BrowserScreenshotUpdateEvent, EventMsg};
|
||||
let _ = app_event_tx.send(AppEvent::CodexEvent(Event { id: uuid::Uuid::new_v4().to_string(), event_seq: 0, msg: EventMsg::BrowserScreenshotUpdate(BrowserScreenshotUpdateEvent { screenshot_path: first_path.clone(), url: url.unwrap_or_else(|| "Browser".to_string()) }), order: None }));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to capture initial external Chrome screenshot: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some((h, p)) = first.rsplit_once(':') {
|
||||
if let Ok(pn) = p.parse::<u16>() { host = Some(h.to_string()); port = Some(pn); }
|
||||
}
|
||||
if host.is_none() && port.is_none() {
|
||||
if let Ok(pn) = first.parse::<u16>() { port = Some(pn); }
|
||||
else if parts.len() >= 2 { if let Ok(pn) = parts[1].parse::<u16>() { host = Some(first.to_string()); port = Some(pn); } }
|
||||
}
|
||||
tracing::info!("[cdp] parsed host={:?}, port={:?}", host, port);
|
||||
self.handle_chrome_connection(host, port);
|
||||
}
|
||||
|
||||
/// Programmatically submit a user text message as if typed in the
|
||||
|
||||
Reference in New Issue
Block a user