//! `bsk emulate` — emulate a mobile device environment on a tab via the //! CDP Emulation domain (viewport metrics, user agent, touch), for //! mobile page debugging. //! //! Device presets are resolved here, CLI-side: the extension only //! executes the concrete override parameters it receives, so presets are //! not duplicated in TypeScript. use std::path::PathBuf; use anyhow::Context; use bsk_protocol::tools::{EmulateOverrides, EmulateParams, EmulateResult}; use bsk_protocol::{ErrorCode, Method, RpcError}; use clap::Args; use crate::cli::TOOL_IPC_TIMEOUT; use crate::cli::ensure_daemon::ensure_daemon; use crate::cli::error::{CliError, Format}; /// One built-in device preset: viewport metrics + UA + touch applied in /// a single `--device` invocation. #[derive(Debug, Clone, Copy, PartialEq)] pub struct DevicePreset { pub name: &'static str, pub width: u32, pub height: u32, pub device_scale_factor: f64, pub mobile: bool, pub user_agent: &'static str, pub max_touch_points: u32, } const IPHONE_UA: &str = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; const IPAD_UA: &str = "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; /// Built-in device presets for `--device`. Keep names lowercase-kebab; /// they are the public CLI identifiers. pub const DEVICE_PRESETS: &[DevicePreset] = &[ DevicePreset { name: "iphone-14", width: 390, height: 844, device_scale_factor: 3.0, mobile: true, user_agent: IPHONE_UA, max_touch_points: 5, }, DevicePreset { name: "iphone-14-pro-max", width: 430, height: 932, device_scale_factor: 3.0, mobile: true, user_agent: IPHONE_UA, max_touch_points: 5, }, DevicePreset { name: "iphone-se", width: 375, height: 667, device_scale_factor: 2.0, mobile: true, user_agent: IPHONE_UA, max_touch_points: 5, }, DevicePreset { name: "pixel-7", width: 412, height: 915, device_scale_factor: 2.625, mobile: true, user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.43 Mobile Safari/537.36", max_touch_points: 5, }, DevicePreset { name: "galaxy-s23", width: 360, height: 780, device_scale_factor: 3.0, mobile: true, user_agent: "Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.43 Mobile Safari/537.36", max_touch_points: 5, }, DevicePreset { name: "ipad-mini", width: 744, height: 1133, device_scale_factor: 2.0, mobile: true, user_agent: IPAD_UA, max_touch_points: 5, }, DevicePreset { name: "galaxy-tab-s8", width: 800, height: 1280, device_scale_factor: 2.0, mobile: true, user_agent: "Mozilla/5.0 (Linux; Android 14; SM-X700) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.43 Safari/537.36", max_touch_points: 5, }, ]; /// Comma-separated preset names for error messages. pub fn preset_names() -> String { DEVICE_PRESETS .iter() .map(|p| p.name) .collect::>() .join(", ") } /// Look up a preset by its CLI name. pub fn find_preset(name: &str) -> Option<&'static DevicePreset> { DEVICE_PRESETS.iter().find(|p| p.name == name) } #[derive(Debug, Clone, Args)] pub struct EmulateArgs { /// Session id (must be active). #[arg(long)] pub session: String, /// Target tab. Defaults to the Agent Window's active tab. #[arg(long = "tab-id")] pub tab_id: Option, /// Built-in device preset (iphone-14, iphone-14-pro-max, iphone-se, /// pixel-7, galaxy-s23, ipad-mini, galaxy-tab-s8). Manual flags /// override individual preset fields. #[arg(long)] pub device: Option, /// Viewport width in CSS pixels (requires --height unless --device /// supplies it). #[arg(long, value_parser = viewport_dimension)] pub width: Option, /// Viewport height in CSS pixels (requires --width unless --device /// supplies it). #[arg(long, value_parser = viewport_dimension)] pub height: Option, /// Device pixel ratio (requires --width/--height without --device). #[arg(long, value_parser = device_pixel_ratio)] pub dpr: Option, /// Emulate a mobile viewport (requires --width/--height without /// --device). #[arg(long)] pub mobile: bool, /// Turn a preset's mobile viewport flag back off. #[arg(long, conflicts_with = "mobile")] pub no_mobile: bool, /// User-Agent override string. #[arg(long)] pub ua: Option, /// Accept-Language header override (requires --ua or --device). #[arg(long = "accept-language")] pub accept_language: Option, /// Enable touch emulation (combines with --device or manual flags). #[arg(long)] pub touch: bool, /// Turn a preset's touch emulation back off. #[arg(long, conflicts_with = "touch")] pub no_touch: bool, /// Touch points reported while touch emulation is enabled (implies /// --touch). #[arg(long = "max-touch-points", value_parser = touch_points)] pub max_touch_points: Option, /// Clear all emulation overrides and restore the tab's real /// environment. Mutually exclusive with every other option. #[arg(long)] pub off: bool, } /// Parse a `--width` / `--height` viewport dimension (CSS pixels). fn viewport_dimension(s: &str) -> Result { let value: u32 = s .parse() .map_err(|_| format!("invalid viewport dimension {s:?}: expected a positive integer"))?; if (1..=7680).contains(&value) { Ok(value) } else { Err(format!( "viewport dimension {value} out of range (1..=7680)" )) } } /// Parse a `--dpr` device pixel ratio. fn device_pixel_ratio(s: &str) -> Result { let value: f64 = s .parse() .map_err(|_| format!("invalid device pixel ratio {s:?}: expected a number"))?; if value.is_finite() && value > 0.0 { Ok(value) } else { Err(format!( "device pixel ratio {value} out of range (must be positive and finite)" )) } } /// Parse a `--max-touch-points` value. fn touch_points(s: &str) -> Result { let value: u32 = s .parse() .map_err(|_| format!("invalid max touch points {s:?}: expected a positive integer"))?; if value >= 1 { Ok(value) } else { Err("max touch points must be at least 1".to_string()) } } fn invalid_params(message: &str) -> CliError { CliError::from_rpc(RpcError { code: ErrorCode::InvalidParams, message: message.to_string(), data: None, }) } /// Validate the argument combination and build the wire params. Pure so /// it can be unit-tested without a daemon. /// /// The preset (if any) forms the base, manual flags override individual /// fields, and only the merged result is validated — so a preset can /// supply the dimension a lone `--width`/`--height` lacks. pub fn build_params(args: &EmulateArgs) -> Result { let has_manual = args.width.is_some() || args.height.is_some() || args.dpr.is_some() || args.mobile || args.no_mobile || args.ua.is_some() || args.accept_language.is_some() || args.touch || args.no_touch || args.max_touch_points.is_some(); if args.off { if args.device.is_some() || has_manual { return Err(invalid_params( "--off cannot be combined with --device or manual emulation options", )); } return Ok(EmulateParams { session_id: args.session.clone(), tab_id: args.tab_id, off: Some(true), overrides: None, }); } if args.device.is_none() && !has_manual { return Err(invalid_params(&format!( "nothing to do: pass --device ({}), manual options (--width/--height/--dpr/--mobile/--ua/--touch), or --off", preset_names() ))); } let mut overrides = match args.device.as_deref() { Some(name) => { let preset = find_preset(name).ok_or_else(|| { invalid_params(&format!( "unknown device preset {name:?} (available: {})", preset_names() )) })?; EmulateOverrides { width: Some(preset.width), height: Some(preset.height), device_scale_factor: Some(preset.device_scale_factor), mobile: Some(preset.mobile), user_agent: Some(preset.user_agent.to_string()), accept_language: None, user_agent_metadata: None, touch: Some(true), max_touch_points: Some(preset.max_touch_points), } } None => EmulateOverrides::default(), }; if let Some(w) = args.width { overrides.width = Some(w); } if let Some(h) = args.height { overrides.height = Some(h); } if let Some(dpr) = args.dpr { overrides.device_scale_factor = Some(dpr); } if args.mobile { overrides.mobile = Some(true); } if args.no_mobile { overrides.mobile = Some(false); } if let Some(ua) = &args.ua { overrides.user_agent = Some(ua.clone()); } if let Some(accept_language) = &args.accept_language { overrides.accept_language = Some(accept_language.clone()); } if args.touch || args.max_touch_points.is_some() { overrides.touch = Some(true); } if args.no_touch { overrides.touch = Some(false); } if let Some(max_touch_points) = args.max_touch_points { overrides.max_touch_points = Some(max_touch_points); } // Cross-field checks run on the merged result. match (overrides.width, overrides.height) { (Some(_), None) | (None, Some(_)) => { return Err(invalid_params( "--width and --height must be provided together", )); } _ => {} } if overrides.width.is_none() && (overrides.device_scale_factor.is_some() || overrides.mobile.is_some()) { return Err(invalid_params( "--dpr/--mobile/--no-mobile require --width/--height (viewport metrics need dimensions)", )); } if overrides.accept_language.is_some() && overrides.user_agent.is_none() { return Err(invalid_params("--accept-language requires --ua")); } Ok(EmulateParams { session_id: args.session.clone(), tab_id: args.tab_id, off: None, overrides: Some(overrides), }) } pub fn dispatch(args: EmulateArgs, format: Format) -> Result<(), CliError> { let device = args.device.clone(); let params = build_params(&args)?; let info = ensure_daemon().context("ensure daemon is running")?; let reply = call(info.sock_path, params)?; render(&reply, device.as_deref(), format) } fn call(sock: PathBuf, params: EmulateParams) -> Result { crate::cli::business_rpc::call::( sock, "emulate", Method::ToolEmulate, Some(params), TOOL_IPC_TIMEOUT, ) } fn render(reply: &EmulateResult, device: Option<&str>, format: Format) -> Result<(), CliError> { match format { Format::Json => { let json = serde_json::to_string_pretty(reply) .map_err(|e| CliError::Local(anyhow::anyhow!(e)))?; println!("{json}"); } Format::Human => { if reply.cleared { println!("tab={} emulation cleared", reply.tab_id); } else { print!("tab={} emulation applied", reply.tab_id); if let Some(device) = device { print!(" device={device}"); } if let Some(applied) = &reply.applied { if let (Some(w), Some(h)) = (applied.width, applied.height) { print!(" viewport={w}x{h}"); if let Some(dpr) = applied.device_scale_factor { print!("@{dpr}x"); } if applied.mobile == Some(true) { print!(" mobile"); } } if applied.user_agent.is_some() { print!(" ua"); } if applied.touch == Some(true) { print!(" touch"); } } println!(); } if let Some(note) = &reply.note { println!("note: {note}"); } } } Ok(()) } #[cfg(test)] mod tests { use super::*; fn base_args() -> EmulateArgs { EmulateArgs { session: "s1".into(), tab_id: None, device: None, width: None, height: None, dpr: None, mobile: false, no_mobile: false, ua: None, accept_language: None, touch: false, no_touch: false, max_touch_points: None, off: false, } } fn build(args: &EmulateArgs) -> EmulateParams { build_params(args).expect("build_params should succeed") } #[test] fn preset_alone_applies_full_environment() { let args = EmulateArgs { device: Some("iphone-14".into()), ..base_args() }; let params = build(&args); let o = params.overrides.unwrap(); assert_eq!(o.width, Some(390)); assert_eq!(o.height, Some(844)); assert_eq!(o.device_scale_factor, Some(3.0)); assert_eq!(o.mobile, Some(true)); assert!(o.user_agent.unwrap().contains("iPhone")); assert_eq!(o.touch, Some(true)); assert_eq!(o.max_touch_points, Some(5)); assert!(params.off.is_none()); } #[test] fn every_preset_is_applicable() { assert_eq!(DEVICE_PRESETS.len(), 7); for preset in DEVICE_PRESETS { let args = EmulateArgs { device: Some(preset.name.into()), ..base_args() }; let o = build(&args).overrides.unwrap(); assert_eq!(o.width, Some(preset.width)); assert_eq!(o.height, Some(preset.height)); assert!(o.user_agent.is_some()); } } #[test] fn manual_flags_override_preset_fields() { let args = EmulateArgs { device: Some("pixel-7".into()), width: Some(500), height: Some(900), dpr: Some(2.0), ua: Some("custom-ua".into()), ..base_args() }; let o = build(&args).overrides.unwrap(); assert_eq!(o.width, Some(500)); assert_eq!(o.height, Some(900)); assert_eq!(o.device_scale_factor, Some(2.0)); assert_eq!(o.user_agent.as_deref(), Some("custom-ua")); // Untouched preset fields survive. assert_eq!(o.mobile, Some(true)); assert_eq!(o.touch, Some(true)); } #[test] fn manual_only_viewport_and_ua() { let args = EmulateArgs { width: Some(390), height: Some(844), dpr: Some(3.0), mobile: true, ua: Some("Mozilla/5.0 (iPhone…)".into()), touch: true, ..base_args() }; let o = build(&args).overrides.unwrap(); assert_eq!(o.width, Some(390)); assert_eq!(o.device_scale_factor, Some(3.0)); assert_eq!(o.mobile, Some(true)); assert_eq!(o.touch, Some(true)); assert_eq!(o.max_touch_points, None); } #[test] fn touch_alone_is_valid() { let args = EmulateArgs { touch: true, ..base_args() }; let o = build(&args).overrides.unwrap(); assert_eq!(o.touch, Some(true)); assert!(o.width.is_none()); } #[test] fn max_touch_points_implies_touch() { let args = EmulateArgs { max_touch_points: Some(5), ..base_args() }; let o = build(&args).overrides.unwrap(); assert_eq!(o.touch, Some(true)); assert_eq!(o.max_touch_points, Some(5)); } #[test] fn off_builds_clear_params() { let args = EmulateArgs { off: true, ..base_args() }; let params = build(&args); assert_eq!(params.off, Some(true)); assert!(params.overrides.is_none()); } #[test] fn off_is_exclusive() { for args in [ EmulateArgs { off: true, device: Some("iphone-14".into()), ..base_args() }, EmulateArgs { off: true, touch: true, ..base_args() }, EmulateArgs { off: true, width: Some(390), height: Some(844), ..base_args() }, ] { let err = build_params(&args).unwrap_err(); assert!(err.to_string().contains("--off")); } } #[test] fn empty_invocation_is_rejected_with_guidance() { let err = build_params(&base_args()).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("--device")); assert!(msg.contains("--off")); assert!(msg.contains("iphone-14")); } #[test] fn unknown_preset_lists_available() { let args = EmulateArgs { device: Some("nokia-3310".into()), ..base_args() }; let err = build_params(&args).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("nokia-3310")); assert!(msg.contains("iphone-14")); } #[test] fn width_height_must_pair() { for args in [ EmulateArgs { width: Some(390), ..base_args() }, EmulateArgs { height: Some(844), ..base_args() }, ] { let err = build_params(&args).unwrap_err(); assert!(err.to_string().contains("together")); } } #[test] fn dpr_and_mobile_require_dimensions_without_preset() { for args in [ EmulateArgs { dpr: Some(3.0), ..base_args() }, EmulateArgs { mobile: true, ..base_args() }, ] { let err = build_params(&args).unwrap_err(); assert!(err.to_string().contains("--width/--height")); } } #[test] fn dpr_without_dimensions_is_fine_with_preset() { let args = EmulateArgs { device: Some("iphone-se".into()), dpr: Some(2.5), ..base_args() }; let o = build(&args).overrides.unwrap(); assert_eq!(o.device_scale_factor, Some(2.5)); assert_eq!(o.width, Some(375)); } #[test] fn preset_supplies_the_missing_dimension() { // Pairing is validated on the merged result, so a lone --width // keeps the preset's height instead of erroring out. let args = EmulateArgs { device: Some("iphone-14".into()), width: Some(500), ..base_args() }; let o = build(&args).overrides.unwrap(); assert_eq!(o.width, Some(500)); assert_eq!(o.height, Some(844)); // Same for a lone --height. let args = EmulateArgs { device: Some("iphone-14".into()), height: Some(900), ..base_args() }; let o = build(&args).overrides.unwrap(); assert_eq!(o.width, Some(390)); assert_eq!(o.height, Some(900)); } #[test] fn no_mobile_and_no_touch_override_preset_fields() { let args = EmulateArgs { device: Some("iphone-14".into()), no_mobile: true, no_touch: true, ..base_args() }; let o = build(&args).overrides.unwrap(); assert_eq!(o.mobile, Some(false)); assert_eq!(o.touch, Some(false)); // Untouched preset fields survive. assert_eq!(o.width, Some(390)); assert_eq!(o.max_touch_points, Some(5)); } #[test] fn no_mobile_without_dimensions_is_rejected() { // --no-mobile still maps to the mobile field, which needs a // viewport without a preset. let args = EmulateArgs { no_mobile: true, ..base_args() }; let err = build_params(&args).unwrap_err(); assert!(err.to_string().contains("--width/--height")); } #[test] fn no_touch_alone_is_valid() { let args = EmulateArgs { no_touch: true, ..base_args() }; let o = build(&args).overrides.unwrap(); assert_eq!(o.touch, Some(false)); assert!(o.width.is_none()); } #[test] fn accept_language_requires_ua() { let args = EmulateArgs { accept_language: Some("zh-CN".into()), ..base_args() }; let err = build_params(&args).unwrap_err(); assert!(err.to_string().contains("--ua")); // …but pairs fine with a preset (which carries a UA). let args = EmulateArgs { device: Some("iphone-14".into()), accept_language: Some("zh-CN".into()), ..base_args() }; let o = build(&args).overrides.unwrap(); assert_eq!(o.accept_language.as_deref(), Some("zh-CN")); } #[test] fn preset_names_are_unique_and_listed() { let names = preset_names(); for preset in DEVICE_PRESETS { assert!(names.contains(preset.name)); assert_eq!(find_preset(preset.name), Some(preset)); } assert!(find_preset("IPHONE-14").is_none()); } }