* fix: use custom viewport dimensions in streaming frame metadata CDP's Page.screencastFrame metadata returns physical device dimensions instead of the emulated viewport, causing frame messages to report incorrect deviceWidth/deviceHeight when a custom viewport is set. Use the viewport dimensions captured at screencast start instead of the CDP metadata values, since the screencast image is already captured at the configured viewport size. Closes #1031 * fix: resize browser content area on viewport change for correct screencast dimensions Emulation.setDeviceMetricsOverride only changes the CSS viewport, but screencast captures the actual browser content area. This caused frame images to have incorrect dimensions (e.g., 1000x451 instead of 1000x1000) when a custom viewport was set. - Call Browser.setContentsSize after setDeviceMetricsOverride so the content area matches the emulated viewport - Restart active screencast when viewport dimensions change so maxWidth/maxHeight parameters are updated - Skip redundant screencast restarts when dimensions are unchanged - Extend E2E test to verify actual JPEG image dimensions, not just metadata * fix: pass viewport dimensions to --window-size at launch and log setContentsSize failures - Add viewport_size to LaunchOptions so --window-size matches the configured viewport from the start, reducing reliance on the experimental Browser.setContentsSize CDP call at runtime - Log Browser.setContentsSize failures instead of silently ignoring them with let _ = * fix: remove duplicate viewport change detection block (dead code from merge) * fix: use log::debug! instead of eprintln! for setContentsSize failure * revert: use eprintln! instead of log crate for setContentsSize failure The daemon's stderr pipe is closed after startup, so log crate subscribers cannot output during normal operation. eprintln! is visible during startup and in tests, matching the existing convention. --------- Co-authored-by: hyunjinee <leehj0110@kakao.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
326 lines
16 KiB
Rust
326 lines
16 KiB
Rust
use serde_json::{json, Value};
|
|
use std::sync::Arc;
|
|
|
|
use tokio::sync::{broadcast, watch, Mutex, RwLock};
|
|
|
|
use crate::native::cdp::client::CdpClient;
|
|
use crate::native::network;
|
|
|
|
use super::timestamp_ms;
|
|
|
|
/// Background task that subscribes to CDP events and broadcasts screencast frames in real-time.
|
|
/// Also handles auto-start/stop of screencast based on WebSocket client count.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(super) async fn cdp_event_loop(
|
|
frame_tx: broadcast::Sender<String>,
|
|
client_slot: Arc<RwLock<Option<Arc<CdpClient>>>>,
|
|
client_notify: Arc<tokio::sync::Notify>,
|
|
screencasting: Arc<Mutex<bool>>,
|
|
client_count: Arc<Mutex<usize>>,
|
|
cdp_session_id: Arc<RwLock<Option<String>>>,
|
|
viewport_width: Arc<Mutex<u32>>,
|
|
viewport_height: Arc<Mutex<u32>>,
|
|
last_frame: Arc<RwLock<Option<String>>>,
|
|
last_tabs: Arc<RwLock<Vec<Value>>>,
|
|
last_engine: Arc<RwLock<String>>,
|
|
recording: Arc<Mutex<bool>>,
|
|
mut shutdown_rx: watch::Receiver<bool>,
|
|
) {
|
|
loop {
|
|
tokio::select! {
|
|
changed = shutdown_rx.changed() => {
|
|
if changed.is_err() || *shutdown_rx.borrow() {
|
|
let session_id = cdp_session_id.read().await.clone();
|
|
if *screencasting.lock().await {
|
|
if let Some(ref client) = *client_slot.read().await {
|
|
let _ = client
|
|
.send_command_no_params("Page.stopScreencast", session_id.as_deref())
|
|
.await;
|
|
}
|
|
let mut sc = screencasting.lock().await;
|
|
*sc = false;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
_ = client_notify.notified() => {}
|
|
}
|
|
|
|
let count = *client_count.lock().await;
|
|
let guard = client_slot.read().await;
|
|
|
|
if count > 0 {
|
|
if let Some(ref client) = *guard {
|
|
let mut event_rx = client.subscribe();
|
|
let client_arc = Arc::clone(client);
|
|
drop(guard);
|
|
|
|
let session_id = cdp_session_id.read().await.clone();
|
|
|
|
let vw = *viewport_width.lock().await;
|
|
let vh = *viewport_height.lock().await;
|
|
|
|
let eng = last_engine.read().await.clone();
|
|
let supports_screencast = eng == "chrome";
|
|
|
|
if supports_screencast {
|
|
let _ = client_arc
|
|
.send_command(
|
|
"Page.startScreencast",
|
|
Some(json!({
|
|
"format": "jpeg",
|
|
"quality": 80,
|
|
"maxWidth": vw,
|
|
"maxHeight": vh,
|
|
"everyNthFrame": 1,
|
|
})),
|
|
session_id.as_deref(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
{
|
|
let mut sc = screencasting.lock().await;
|
|
*sc = supports_screencast;
|
|
}
|
|
|
|
let rec = *recording.lock().await;
|
|
let status = json!({
|
|
"type": "status",
|
|
"connected": true,
|
|
"screencasting": supports_screencast,
|
|
"viewportWidth": vw,
|
|
"viewportHeight": vh,
|
|
"engine": eng,
|
|
"recording": rec,
|
|
});
|
|
let _ = frame_tx.send(status.to_string());
|
|
|
|
loop {
|
|
tokio::select! {
|
|
changed = shutdown_rx.changed() => {
|
|
if changed.is_err() || *shutdown_rx.borrow() {
|
|
if supports_screencast {
|
|
let session_id = cdp_session_id.read().await.clone();
|
|
let _ = client_arc
|
|
.send_command_no_params("Page.stopScreencast", session_id.as_deref())
|
|
.await;
|
|
}
|
|
let mut sc = screencasting.lock().await;
|
|
*sc = false;
|
|
return;
|
|
}
|
|
}
|
|
event = event_rx.recv() => {
|
|
match event {
|
|
Ok(evt) => {
|
|
if evt.method == "Page.frameNavigated" {
|
|
if let Some(frame) = evt.params.get("frame") {
|
|
let is_main = frame
|
|
.get("parentId")
|
|
.and_then(|v| v.as_str())
|
|
.is_none_or(|s| s.is_empty());
|
|
if is_main {
|
|
if let Some(url) = frame.get("url").and_then(|v| v.as_str()) {
|
|
{
|
|
let mut tabs = last_tabs.write().await;
|
|
for tab in tabs.iter_mut() {
|
|
if tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false) {
|
|
tab.as_object_mut().map(|o| o.insert("url".to_string(), json!(url)));
|
|
}
|
|
}
|
|
}
|
|
let msg = json!({
|
|
"type": "url",
|
|
"url": url,
|
|
"timestamp": timestamp_ms(),
|
|
});
|
|
let _ = frame_tx.send(msg.to_string());
|
|
}
|
|
}
|
|
}
|
|
} else if evt.method == "Page.screencastFrame" {
|
|
if let Some(sid) = evt.params.get("sessionId").and_then(|v| v.as_i64()) {
|
|
let _ = client_arc.send_command(
|
|
"Page.screencastFrameAck",
|
|
Some(json!({ "sessionId": sid })),
|
|
evt.session_id.as_deref(),
|
|
).await;
|
|
}
|
|
|
|
if let Some(data) = evt.params.get("data").and_then(|v| v.as_str()) {
|
|
let meta = evt.params.get("metadata");
|
|
let msg = json!({
|
|
"type": "frame",
|
|
"data": data,
|
|
"metadata": {
|
|
"offsetTop": meta.and_then(|m| m.get("offsetTop")).and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"pageScaleFactor": meta.and_then(|m| m.get("pageScaleFactor")).and_then(|v| v.as_f64()).unwrap_or(1.0),
|
|
"deviceWidth": vw,
|
|
"deviceHeight": vh,
|
|
"scrollOffsetX": meta.and_then(|m| m.get("scrollOffsetX")).and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"scrollOffsetY": meta.and_then(|m| m.get("scrollOffsetY")).and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
"timestamp": meta.and_then(|m| m.get("timestamp")).and_then(|v| v.as_u64()).unwrap_or(0),
|
|
}
|
|
});
|
|
let msg_str = msg.to_string();
|
|
{
|
|
let mut lf = last_frame.write().await;
|
|
*lf = Some(msg_str.clone());
|
|
}
|
|
let _ = frame_tx.send(msg_str);
|
|
}
|
|
} else if evt.method == "Runtime.consoleAPICalled" {
|
|
let level = evt.params.get("type")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("log");
|
|
let raw_args = evt.params.get("args")
|
|
.and_then(|v| v.as_array())
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let text = network::format_console_args(&raw_args);
|
|
if !text.is_empty() {
|
|
let mut msg = json!({
|
|
"type": "console",
|
|
"level": level,
|
|
"text": text,
|
|
"timestamp": timestamp_ms(),
|
|
});
|
|
if !raw_args.is_empty() {
|
|
msg.as_object_mut().unwrap().insert(
|
|
"args".to_string(),
|
|
Value::Array(raw_args),
|
|
);
|
|
}
|
|
let _ = frame_tx.send(msg.to_string());
|
|
}
|
|
} else if evt.method == "Runtime.exceptionThrown" {
|
|
let text = evt.params.get("exceptionDetails")
|
|
.and_then(|d| {
|
|
d.get("exception")
|
|
.and_then(|e| e.get("description").and_then(|v| v.as_str()))
|
|
.or_else(|| d.get("text").and_then(|v| v.as_str()))
|
|
})
|
|
.unwrap_or("Unknown error");
|
|
let line = evt.params.get("exceptionDetails")
|
|
.and_then(|d| d.get("lineNumber").and_then(|v| v.as_i64()));
|
|
let column = evt.params.get("exceptionDetails")
|
|
.and_then(|d| d.get("columnNumber").and_then(|v| v.as_i64()));
|
|
let msg = json!({
|
|
"type": "page_error",
|
|
"text": text,
|
|
"line": line,
|
|
"column": column,
|
|
"timestamp": timestamp_ms(),
|
|
});
|
|
let _ = frame_tx.send(msg.to_string());
|
|
}
|
|
}
|
|
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
|
Err(broadcast::error::RecvError::Closed) => break,
|
|
}
|
|
}
|
|
_ = client_notify.notified() => {
|
|
let count = *client_count.lock().await;
|
|
let new_session_id = cdp_session_id.read().await.clone();
|
|
if count == 0 {
|
|
if supports_screencast {
|
|
let _ = client_arc
|
|
.send_command_no_params("Page.stopScreencast", session_id.as_deref())
|
|
.await;
|
|
}
|
|
let mut sc = screencasting.lock().await;
|
|
*sc = false;
|
|
break;
|
|
}
|
|
let client_changed = {
|
|
let guard = client_slot.read().await;
|
|
let same = guard
|
|
.as_ref()
|
|
.is_some_and(|c| Arc::ptr_eq(c, &client_arc));
|
|
!same
|
|
};
|
|
let session_changed = new_session_id != session_id;
|
|
let new_vw = *viewport_width.lock().await;
|
|
let new_vh = *viewport_height.lock().await;
|
|
let viewport_changed = new_vw != vw || new_vh != vh;
|
|
if client_changed || session_changed || viewport_changed {
|
|
if supports_screencast {
|
|
let _ = client_arc
|
|
.send_command_no_params("Page.stopScreencast", session_id.as_deref())
|
|
.await;
|
|
}
|
|
let mut sc = screencasting.lock().await;
|
|
*sc = false;
|
|
client_notify.notify_one();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
drop(guard);
|
|
}
|
|
} else {
|
|
let was_screencasting = *screencasting.lock().await;
|
|
if was_screencasting {
|
|
if let Some(ref client) = *guard {
|
|
let session_id = cdp_session_id.read().await.clone();
|
|
let _ = client
|
|
.send_command_no_params("Page.stopScreencast", session_id.as_deref())
|
|
.await;
|
|
}
|
|
let mut sc = screencasting.lock().await;
|
|
*sc = false;
|
|
}
|
|
drop(guard);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn start_screencast(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
format: &str,
|
|
quality: i32,
|
|
max_width: i32,
|
|
max_height: i32,
|
|
) -> Result<(), String> {
|
|
client
|
|
.send_command(
|
|
"Page.startScreencast",
|
|
Some(json!({
|
|
"format": format,
|
|
"quality": quality,
|
|
"maxWidth": max_width,
|
|
"maxHeight": max_height,
|
|
"everyNthFrame": 1,
|
|
})),
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn stop_screencast(client: &CdpClient, session_id: &str) -> Result<(), String> {
|
|
client
|
|
.send_command_no_params("Page.stopScreencast", Some(session_id))
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn ack_screencast_frame(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
screencast_session_id: i64,
|
|
) -> Result<(), String> {
|
|
client
|
|
.send_command(
|
|
"Page.screencastFrameAck",
|
|
Some(json!({ "sessionId": screencast_session_id })),
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|