diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 6e3681b..4353df9 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -3162,15 +3162,21 @@ fn parse_mouse(rest: &[&str], id: &str) -> Result { /// viewport x (e.g. 1280x800) /// viewport reset | clear (drop the override, restore real size) fn parse_viewport(rest: &[&str], id: &str) -> Result { - const USAGE: &str = - "viewport [scale] [--dpr N] [--mobile] | viewport reset"; + const USAGE: &str = "viewport [scale] [--dpr N] [--mobile] | viewport reset"; - if matches!(rest.first().copied(), Some("reset") | Some("clear") | Some("off")) { + if matches!( + rest.first().copied(), + Some("reset") | Some("clear") | Some("off") + ) { return Ok(json!({ "id": id, "action": "viewport", "reset": true })); } // Positional (non-flag) tokens. A `WxH` token counts as one positional. - let positionals: Vec<&str> = rest.iter().copied().filter(|a| !a.starts_with("--")).collect(); + let positionals: Vec<&str> = rest + .iter() + .copied() + .filter(|a| !a.starts_with("--")) + .collect(); let (w, h, scale_tok): (i32, i32, Option<&str>) = match positionals.first() { Some(first) if first.contains('x') || first.contains('X') => { @@ -3188,9 +3194,10 @@ fn parse_viewport(rest: &[&str], id: &str) -> Result { } } Some(w_str) => { - let h_str = positionals - .get(1) - .ok_or(ParseError::MissingArguments { context: "viewport".to_string(), usage: USAGE })?; + let h_str = positionals.get(1).ok_or(ParseError::MissingArguments { + context: "viewport".to_string(), + usage: USAGE, + })?; let w = w_str.parse::().map_err(|_| ParseError::InvalidValue { message: format!("Invalid width: {}", w_str), usage: USAGE, @@ -3220,13 +3227,12 @@ fn parse_viewport(rest: &[&str], id: &str) -> Result { None => None, }; if let Some(i) = rest.iter().position(|a| *a == "--dpr" || *a == "--scale") { - let v = rest - .get(i + 1) - .and_then(|s| s.parse::().ok()) - .ok_or(ParseError::InvalidValue { + let v = rest.get(i + 1).and_then(|s| s.parse::().ok()).ok_or( + ParseError::InvalidValue { message: "--dpr/--scale needs a number".to_string(), usage: USAGE, - })?; + }, + )?; scale = Some(v); } if let Some(s) = scale { @@ -5442,7 +5448,8 @@ mod tests { #[test] fn test_viewport_dpr_and_mobile_flags() { - let cmd = parse_command(&args("viewport 375 812 --dpr 3 --mobile"), &default_flags()).unwrap(); + let cmd = + parse_command(&args("viewport 375 812 --dpr 3 --mobile"), &default_flags()).unwrap(); assert_eq!(cmd["action"], "viewport"); assert_eq!(cmd["width"], 375); assert_eq!(cmd["height"], 812); diff --git a/cli/src/native/browser.rs b/cli/src/native/browser.rs index a32ba2c..3b08e98 100644 --- a/cli/src/native/browser.rs +++ b/cli/src/native/browser.rs @@ -1950,9 +1950,17 @@ impl BrowserManager { /// CDP browser the endpoint is strict, so we must NOT send the custom param — /// hence `None` there. We detect the relay by matching our `ws_url` against /// the live relay URL the native-messaging host published. + /// Whether this manager is driving the user's real Chrome through the + /// `ab-connect` extension relay (vs. a browser we launched or a direct CDP + /// endpoint). Detected by matching our `ws_url` against the live relay URL + /// the native-messaging host published. Used to avoid relay-unsafe CDP that + /// would disturb the user's window (e.g. Browser.setContentsSize, issue #47). + fn via_relay(&self) -> bool { + crate::connect::relay_url().as_deref() == Some(self.ws_url.as_str()) + } + fn agent_group(&self) -> Option { - let via_relay = crate::connect::relay_url().as_deref() == Some(self.ws_url.as_str()); - if !via_relay { + if !self.via_relay() { return None; } let name = DAEMON_SESSION @@ -2161,32 +2169,39 @@ impl BrowserManager { .await?; // Screencast captures the actual content area, not the emulated CSS - // viewport, so resize the content area to match. - if let Ok(target_id) = self.active_target_id() { - if let Ok(window_info) = self - .client - .send_command( - "Browser.getWindowForTarget", - Some(json!({ "targetId": target_id })), - None, - ) - .await - { - if let Some(window_id) = window_info.get("windowId").and_then(|v| v.as_i64()) { - if let Err(e) = self - .client - .send_command( - "Browser.setContentsSize", - Some(json!({ - "windowId": window_id, - "width": width, - "height": height, - })), - None, - ) - .await - { - eprintln!("Browser.setContentsSize failed (experimental CDP): {e}"); + // viewport, so resize the content area to match — but ONLY for a browser + // we launched. Over the ab-connect relay the "window" is the user's real + // Chrome window, and Browser.setContentsSize would physically resize it + // (issue #47) — the exact thing the CDP device-metrics override exists to + // avoid. The Emulation override above already gives the tab the requested + // CSS viewport without touching the OS window, so skip the resize there. + if !self.via_relay() { + if let Ok(target_id) = self.active_target_id() { + if let Ok(window_info) = self + .client + .send_command( + "Browser.getWindowForTarget", + Some(json!({ "targetId": target_id })), + None, + ) + .await + { + if let Some(window_id) = window_info.get("windowId").and_then(|v| v.as_i64()) { + if let Err(e) = self + .client + .send_command( + "Browser.setContentsSize", + Some(json!({ + "windowId": window_id, + "width": width, + "height": height, + })), + None, + ) + .await + { + eprintln!("Browser.setContentsSize failed (experimental CDP): {e}"); + } } } } @@ -2195,6 +2210,22 @@ impl BrowserManager { Ok(()) } + /// Clear the CDP device-metrics override (`viewport reset`), restoring the + /// tab's real layout viewport. Never touches the OS window, so it is safe on + /// the relay (we never physically resized the user's window — see + /// `set_viewport`). + pub async fn clear_viewport(&self) -> Result<(), String> { + let session_id = self.active_session_id()?; + self.client + .send_command( + "Emulation.clearDeviceMetricsOverride", + Some(json!({})), + Some(session_id), + ) + .await?; + Ok(()) + } + pub async fn set_user_agent(&self, user_agent: &str) -> Result<(), String> { let session_id = self.active_session_id()?; self.client