diff --git a/app/src/features/browser/browser.test.tsx b/app/src/features/browser/browser.test.tsx index 8f7ef197..a02b3f00 100644 --- a/app/src/features/browser/browser.test.tsx +++ b/app/src/features/browser/browser.test.tsx @@ -82,7 +82,9 @@ describe("browser panel", () => { fireEvent.click(toggle()); const resized = () => fake.calls.filter((call) => call.method === "browser.resize").map((call) => call.params); - await waitFor(() => expect(resized()).toEqual([{ botId: scout.id, width: 560, height: 804 }])); + await waitFor(() => + expect(resized()).toEqual([{ botId: scout.id, width: 560, height: 804, scale: 100 }]), + ); // A narrow room keeps the desktop layout's width, in the room's shape. expect(fake.browser.size(scout.id)).toEqual({ width: 800, height: 1148 }); diff --git a/app/src/features/browser/useRoom.ts b/app/src/features/browser/useRoom.ts index f0b56842..eae647c3 100644 --- a/app/src/features/browser/useRoom.ts +++ b/app/src/features/browser/useRoom.ts @@ -49,21 +49,24 @@ export function useRoom(): [Size | null, (node: HTMLElement | null) => void] { /** * Tells the daemon the room the page has, once it stops changing, while - * this connection watches the bot's browser. + * this connection watches the bot's browser, and how sharp the screen is, + * so the page is drawn with as many pixels as show it (spec 21.3). */ export function useFitPage(bot: Pick, room: Size | null, watched: boolean): void { const api = useApi(); const known = room !== null; const width = Math.max(1, room?.width ?? 0); const height = Math.max(1, room?.height ?? 0); + // A change of the app's zoom changes the room too, which reads it again. + const scale = Math.round((typeof devicePixelRatio === "number" ? devicePixelRatio : 1) * 100); useEffect(() => { if (!watched || !known) { return; } const timer = setTimeout(() => { // The next change of the room asks again. - api.call("browser.resize", { botId: bot.id, width, height }).catch(() => {}); + api.call("browser.resize", { botId: bot.id, width, height, scale }).catch(() => {}); }, SETTLED_MS); return () => clearTimeout(timer); - }, [api, bot.id, watched, known, width, height]); + }, [api, bot.id, watched, known, width, height, scale]); } diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 10b47a15..d3a103b6 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -1340,7 +1340,13 @@ export type BrowserWatchParams = { botId: BotId, }; * `browser.resize`: the room the app's panel has for the page, in the * app's pixels. The page takes its shape (spec 21.3). */ -export type BrowserResizeParams = { botId: BotId, width: number, height: number, }; +export type BrowserResizeParams = { botId: BotId, width: number, height: number, +/** + * The screen's pixels per pixel of the app, in percent (its + * `devicePixelRatio` times 100): the page is drawn that sharp for the + * owner, up to twice (spec 21.3). Absent, 100. + */ +scale?: number, }; export type MouseAction = "move" | "down" | "up"; diff --git a/crates/botloft-core/src/protocol/browser.rs b/crates/botloft-core/src/protocol/browser.rs index 0360b8f5..7b51fbd7 100644 --- a/crates/botloft-core/src/protocol/browser.rs +++ b/crates/botloft-core/src/protocol/browser.rs @@ -166,4 +166,10 @@ pub struct BrowserResizeParams { pub bot_id: BotId, pub width: u32, pub height: u32, + /// The screen's pixels per pixel of the app, in percent (its + /// `devicePixelRatio` times 100): the page is drawn that sharp for the + /// owner, up to twice (spec 21.3). Absent, 100. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, ts(optional))] + pub scale: Option, } diff --git a/crates/botloftd/src/browser/events.rs b/crates/botloftd/src/browser/events.rs index dfbc0912..10bfeb4b 100644 --- a/crates/botloftd/src/browser/events.rs +++ b/crates/botloftd/src/browser/events.rs @@ -11,7 +11,7 @@ use tokio::sync::mpsc; use tracing::debug; use super::cdp::CdpEvent; -use super::session::{FRAME_GAP, Hooks, MAX_TABS, PageInfo, Session, Tab}; +use super::session::{FRAME_GAP, Hooks, MAX_TABS, PageInfo, QUICK_GAP, Session, Tab}; use super::titles::retitles; impl Session { @@ -196,29 +196,60 @@ impl Session { } fn frame(&self, session: &str, params: &Value, hooks: &Hooks) { - let (active, viewport) = { + let (active, viewport, gap) = { let tabs = self.lock(); let active = tabs.active().is_some_and(|tab| tab.session == session); - (active, tabs.viewport) + let gap = if tabs.quick { QUICK_GAP } else { FRAME_GAP }; + (active, tabs.viewport, gap) }; - if active && let Some(data) = params["data"].as_str() { - let size = |key: &str, fallback: u32| { - params["metadata"][key] - .as_f64() - .map_or(fallback, |value| value.round() as u32) - }; + let size = |key: &str, fallback: u32| { + params["metadata"][key] + .as_f64() + .map_or(fallback, |value| value.round() as u32) + }; + let (width, height) = ( + size("deviceWidth", viewport.width), + size("deviceHeight", viewport.height), + ); + // The screencast draws in the page's own pixels, however sharp the + // page is (spec 19): on a sharper screen its frame only says the page + // changed, and the frame sent is a screenshot, which is that sharp. + let sharp = active && viewport.scale > 100; + if active + && !sharp + && let Some(data) = params["data"].as_str() + { hooks.frames.send_replace(Some(Arc::new(BrowserFrame { bot_id: hooks.bot.clone(), data: data.to_owned(), - width: size("deviceWidth", viewport.width), - height: size("deviceHeight", viewport.height), + width, + height, }))); } let cdp = self.cdp.clone(); let session = session.to_owned(); let ack = json!({ "sessionId": params["sessionId"] }); + let (frames, bot) = (hooks.frames.clone(), hooks.bot.clone()); tokio::spawn(async move { - tokio::time::sleep(FRAME_GAP).await; + if sharp { + let shot = json!({ "format": "jpeg", "quality": 60, "optimizeForSpeed": true }); + let taken = cdp + .call(Some(&session), "Page.captureScreenshot", shot) + .await; + if let Some(data) = taken + .ok() + .and_then(|shot| shot["data"].as_str().map(str::to_owned)) + { + frames.send_replace(Some(Arc::new(BrowserFrame { + bot_id: bot, + data, + width, + height, + }))); + } + } + // Confirmed only after, so screenshots never pile up. + tokio::time::sleep(gap).await; cdp.send(Some(&session), "Page.screencastFrameAck", ack); }); } diff --git a/crates/botloftd/src/browser/hands.rs b/crates/botloftd/src/browser/hands.rs index 5ca72299..e4b9e988 100644 --- a/crates/botloftd/src/browser/hands.rs +++ b/crates/botloftd/src/browser/hands.rs @@ -281,6 +281,7 @@ impl Drop for Hands { mine }); if released { + self.session.owner_gave_back(); update( &self.slots, &self.events, diff --git a/crates/botloftd/src/browser/moves.rs b/crates/botloftd/src/browser/moves.rs index 2b511469..8a3687d8 100644 --- a/crates/botloftd/src/browser/moves.rs +++ b/crates/botloftd/src/browser/moves.rs @@ -136,14 +136,21 @@ impl Session { Ok(()) } - /// The owner took the browser: remember the tab the bot was on. + /// The owner took the browser: remember the tab the bot was on, and + /// send them frames quicker. pub(super) fn owner_took(&self) { let mut tabs = self.lock(); + tabs.quick = true; if tabs.owner_from.is_none() { tabs.owner_from = tabs.active().map(|tab| tab.target.clone()); } } + /// The owner gave the browser back: frames as for watching the bot. + pub(super) fn owner_gave_back(&self) { + self.lock().quick = false; + } + /// Whether the owner had the browser and left the bot on another tab /// than the one it was on; asked once, by the bot's next tool (spec /// 21.10). diff --git a/crates/botloftd/src/browser/page.rs b/crates/botloftd/src/browser/page.rs index 4b629e87..850a3c88 100644 --- a/crates/botloftd/src/browser/page.rs +++ b/crates/botloftd/src/browser/page.rs @@ -294,21 +294,4 @@ impl Session { self.settle(marks).await; Ok(true) } - - /// The screen as a JPEG, base64. - pub async fn screenshot(&self) -> Result { - let (session, _) = self.page()?; - let shot = self - .cdp - .call( - Some(&session), - "Page.captureScreenshot", - json!({ "format": "jpeg", "quality": 70 }), - ) - .await?; - shot["data"] - .as_str() - .map(str::to_owned) - .ok_or_else(|| BrowserError::Page("the browser returned no picture".to_owned())) - } } diff --git a/crates/botloftd/src/browser/rest.rs b/crates/botloftd/src/browser/rest.rs index e9d3c5d8..410ab182 100644 --- a/crates/botloftd/src/browser/rest.rs +++ b/crates/botloftd/src/browser/rest.rs @@ -123,7 +123,7 @@ impl Browsers { if held || session.is_resting() { return; } - if let Ok(data) = session.screenshot().await { + if let Ok(data) = session.picture().await { let page = session.viewport(); frames.send_replace(Some(Arc::new(BrowserFrame { bot_id: bot.clone(), diff --git a/crates/botloftd/src/browser/session.rs b/crates/botloftd/src/browser/session.rs index 657e274d..379779d7 100644 --- a/crates/botloftd/src/browser/session.rs +++ b/crates/botloftd/src/browser/session.rs @@ -21,8 +21,14 @@ use super::{BrowserError, agent}; /// Most tabs a browser keeps; past this, the one active longest ago closes. pub(super) const MAX_TABS: usize = 6; -/// Frames are confirmed after this, so the browser sends ~15 a second. +/// Frames are confirmed after this, so the browser sends ~15 a second +/// while the owner watches the bot work... pub(super) const FRAME_GAP: Duration = Duration::from_millis(66); +/// ...and ~30 while the browser is in the owner's hands, so the page keeps +/// up with them as they scroll and point (spec 21.8). +pub(super) const QUICK_GAP: Duration = Duration::from_millis(33); +/// The largest side of a frame that only says the page changed. +const SIGNAL: u32 = 64; /// How long the first tab may take to show up. const FIRST_TAB_TIMEOUT: Duration = Duration::from_secs(5); @@ -76,6 +82,8 @@ pub struct Tabs { pub(super) resting: bool, pub closed: bool, pub viewport: Viewport, + /// The owner has it in their hands: frames come quicker. + pub(super) quick: bool, /// The tab the bot was on when the owner took the browser, until the /// bot's next tool (spec 21.10). pub(super) owner_from: Option, @@ -251,9 +259,16 @@ impl Session { pub(super) async fn cast(&self, session: &str, on: bool) { let result = if on { - // Frames come the size of the page, however tall it is. + // Frames come the size of the page, however tall it is. On a + // sharper screen they only say the page changed (`events.rs`), + // so they come small. + let (max_width, max_height) = if self.viewport().scale > 100 { + (SIGNAL, SIGNAL) + } else { + (MAX_WIDTH, MAX_HEIGHT) + }; let params = json!({ - "format": "jpeg", "quality": 60, "maxWidth": MAX_WIDTH, "maxHeight": MAX_HEIGHT, + "format": "jpeg", "quality": 60, "maxWidth": max_width, "maxHeight": max_height, }); self.cdp .call(Some(session), "Page.startScreencast", params) diff --git a/crates/botloftd/src/browser/viewport.rs b/crates/botloftd/src/browser/viewport.rs index bae9c821..493a2476 100644 --- a/crates/botloftd/src/browser/viewport.rs +++ b/crates/botloftd/src/browser/viewport.rs @@ -1,10 +1,12 @@ //! The size of the page (spec 21.3): a desktop screen while the bot works //! alone, and the shape and width of the owner's panel while they watch, -//! so the page shows near its own size there. +//! so the page shows near its own size there, drawn as sharp as their +//! screen. The bot's own pictures stay the size of the page. use serde_json::{Value, json}; use tracing::debug; +use super::BrowserError; use super::session::{FRAME_GAP, Session}; /// The page size bots and the owner see, in CSS pixels. The height is the @@ -18,12 +20,17 @@ pub const MAX_WIDTH: u32 = 1600; /// How short and how tall the owner's panel may make the page. pub const MIN_HEIGHT: u32 = 600; pub const MAX_HEIGHT: u32 = 2000; +/// The sharpest the page is drawn for the owner, in percent: two pixels of +/// their screen to one of the page. +pub const MAX_SCALE: u32 = 200; /// The size of the page, the same in every tab. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Viewport { pub width: u32, pub height: u32, + /// The owner's screen pixels per pixel of the page, in percent. + pub scale: u32, } impl Default for Viewport { @@ -31,6 +38,7 @@ impl Default for Viewport { Self { width: WIDTH, height: HEIGHT, + scale: 100, } } } @@ -47,12 +55,26 @@ impl Viewport { height: u32::try_from(tall) .unwrap_or(MAX_HEIGHT) .clamp(MIN_HEIGHT, MAX_HEIGHT), + scale: 100, } } + /// The same page, drawn `scale` percent as sharp, within limits. + pub fn at_scale(self, scale: u32) -> Self { + Self { + scale: scale.clamp(100, MAX_SCALE), + ..self + } + } + + fn factor(self) -> f64 { + f64::from(self.scale) / 100.0 + } + pub(super) fn metrics(self) -> Value { json!({ - "width": self.width, "height": self.height, "deviceScaleFactor": 1, "mobile": false, + "width": self.width, "height": self.height, "deviceScaleFactor": self.factor(), + "mobile": false, }) } @@ -106,6 +128,44 @@ impl Session { self.cast(&page, true).await; } } + + /// The screen as a JPEG, base64, for the bot: the size of the page, + /// however sharp the owner's screen has it drawn. + pub async fn screenshot(&self) -> Result { + self.capture(false).await + } + + /// The screen as a JPEG, base64, as sharp as the owner's panel draws it. + pub async fn picture(&self) -> Result { + self.capture(true).await + } + + async fn capture(&self, sharp: bool) -> Result { + let (session, _) = self.page()?; + let page = self.viewport(); + let mut params = json!({ "format": "jpeg", "quality": 70 }); + if !sharp && page.scale > 100 { + // The page where it is scrolled to, scaled back down (spec 19). + let metrics = self + .cdp + .call(Some(&session), "Page.getLayoutMetrics", json!({})) + .await?; + let view = &metrics["cssVisualViewport"]; + params["clip"] = json!({ + "x": view["pageX"], "y": view["pageY"], + "width": page.width, "height": page.height, + "scale": 1.0 / page.factor(), + }); + } + let shot = self + .cdp + .call(Some(&session), "Page.captureScreenshot", params) + .await?; + shot["data"] + .as_str() + .map(str::to_owned) + .ok_or_else(|| BrowserError::Page("the browser returned no picture".to_owned())) + } } #[cfg(test)] @@ -120,7 +180,8 @@ mod tests { page(1000, 700), Viewport { width: 1000, - height: 700 + height: 700, + scale: 100, } ); // A narrow one keeps the desktop layout, in the room's shape. @@ -128,7 +189,8 @@ mod tests { page(536, 700), Viewport { width: 800, - height: 1044 + height: 1044, + scale: 100, } ); assert_eq!(page(1280, 800), Viewport::default()); @@ -140,6 +202,18 @@ mod tests { assert_eq!(page(1, u32::MAX).height, MAX_HEIGHT); } + #[test] + fn the_page_is_drawn_as_sharp_as_the_screen_up_to_twice() { + let page = Viewport::fitting(1000, 700); + assert_eq!(page.scale, 100); + assert_eq!(page.at_scale(150).scale, 150); + assert_eq!(page.at_scale(300).scale, MAX_SCALE); + // A screen less sharp than the page still gets one pixel each. + assert_eq!(page.at_scale(80).scale, 100); + assert_eq!(page.at_scale(150).metrics()["deviceScaleFactor"], 1.5); + assert_eq!(page.at_scale(150).width, 1000); + } + #[test] fn points_are_on_the_page_up_to_its_edges() { let page = Viewport::fitting(640, 700); diff --git a/crates/botloftd/src/browser/watch.rs b/crates/botloftd/src/browser/watch.rs index 9d73cc06..7416b2b3 100644 --- a/crates/botloftd/src/browser/watch.rs +++ b/crates/botloftd/src/browser/watch.rs @@ -41,12 +41,13 @@ impl Browsers { .is_some_and(|slot| slot.watchers > 0) } - /// The room a watching app has for the page, `width` by `height`: the - /// page takes its shape, now or when the browser opens (spec 21.3). - pub fn resize(&self, bot: &BotId, width: u32, height: u32) { + /// The room a watching app has for the page, `width` by `height`, on + /// a screen `scale` percent as sharp: the page takes its shape and is + /// drawn that sharp, now or when the browser opens (spec 21.3). + pub fn resize(&self, bot: &BotId, width: u32, height: u32, scale: u32) { { let mut slots = lock(&self.slots); - self.slot(&mut slots, bot).viewport = Viewport::fitting(width, height); + self.slot(&mut slots, bot).viewport = Viewport::fitting(width, height).at_scale(scale); } sync_soon(&self.slots, bot); } diff --git a/crates/botloftd/src/rpc/watching.rs b/crates/botloftd/src/rpc/watching.rs index 3eeb0c24..3472af32 100644 --- a/crates/botloftd/src/rpc/watching.rs +++ b/crates/botloftd/src/rpc/watching.rs @@ -83,9 +83,12 @@ impl Watch { ApiError::validation(format!("width and height go from 1 to {ROOM_MAX}")).into(), ); } - daemon - .browsers - .resize(¶ms.bot_id, params.width, params.height); + daemon.browsers.resize( + ¶ms.bot_id, + params.width, + params.height, + params.scale.unwrap_or(100), + ); Ok(Value::Null) } diff --git a/crates/botloftd/tests/browser_panel.rs b/crates/botloftd/tests/browser_panel.rs index 0ae7c893..0088ad25 100644 --- a/crates/botloftd/tests/browser_panel.rs +++ b/crates/botloftd/tests/browser_panel.rs @@ -1,15 +1,35 @@ //! The owner's panel on a bot's browser with the real Microsoft Edge (spec -//! 21.3, 21.7): the page taking the shape of the room the panel has. -//! Skipped where Edge is not installed. +//! 21.3, 21.7): the page taking the shape of the room the panel has, drawn +//! as sharp as the owner's screen. Skipped where Edge is not installed. mod common; use std::time::Duration; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; use common::Client; use common::browsing::{Browsing, answer_site, call, setup}; use serde_json::{Value, json}; +/// The width and height of a JPEG, base64, from its frame header. +fn jpeg_size(data: &str) -> (u32, u32) { + let bytes = BASE64.decode(data).expect("base64"); + let mut at = 2; + while at + 9 < bytes.len() { + assert_eq!(bytes[at], 0xFF, "a JPEG marker"); + let marker = bytes[at + 1]; + let length = usize::from(bytes[at + 2]) << 8 | usize::from(bytes[at + 3]); + if (0xC0..=0xCF).contains(&marker) && ![0xC4, 0xC8, 0xCC].contains(&marker) { + let height = u32::from(bytes[at + 5]) << 8 | u32::from(bytes[at + 6]); + let width = u32::from(bytes[at + 7]) << 8 | u32::from(bytes[at + 8]); + return (width, height); + } + at += 2 + length; + } + panic!("no size in the picture"); +} + /// Reads the page until it says its window is `size`. async fn until_window(b: &mut Browsing, size: &str) -> String { let mut page = String::new(); @@ -113,3 +133,42 @@ async fn the_page_takes_the_shape_of_the_panel_while_the_owner_watches() { .expect("unwatch"); until_window(&mut b, "1280 x 800").await; } + +#[tokio::test(flavor = "multi_thread")] +async fn the_owner_sees_the_page_as_sharp_as_their_screen_and_the_bot_at_its_size() { + let Some(mut b) = setup().await else { return }; + let id = b.bot["id"].clone(); + b.app + .call("browser.watch", json!({ "botId": id })) + .await + .expect("watch"); + // A screen twice as sharp as the app's pixels. + let room = json!({ "botId": id, "width": 1000, "height": 700, "scale": 200 }); + b.app.call("browser.resize", room).await.expect("resize"); + let opening = call( + &b.mcp, + "browser_open", + json!({ "url": format!("{}/size", b.site) }), + ); + answer_site(&mut b.app, true, None).await; + let page = opening.await.expect("task").expect("page"); + // The page keeps its size; only its pixels double. + assert!(page.contains("Window: 1000 x 700, pixels 2"), "{page}"); + let frame = frame_of(&mut b.app, 700).await; + assert_eq!(frame["width"], 1000); + let data = frame["data"].as_str().expect("picture"); + assert_eq!(jpeg_size(data), (2000, 1400)); + + // The bot's own picture stays the size of the page, scrolled where the + // page is. + b.mcp + .tool_text( + "browser_open", + json!({ "url": format!("{}/tall#end", b.site) }), + ) + .await + .expect("tall page"); + let picture = b.mcp.tool_result("browser_screenshot", json!({})).await; + let data = picture["content"][0]["data"].as_str().expect("picture"); + assert_eq!(jpeg_size(data), (1000, 700)); +} diff --git a/crates/botloftd/tests/common/site.rs b/crates/botloftd/tests/common/site.rs index 043f9e6b..5fbdb53d 100644 --- a/crates/botloftd/tests/common/site.rs +++ b/crates/botloftd/tests/common/site.rs @@ -41,8 +41,14 @@ const LOGIN: &str = "Sign in\ +
\ +
"; /// Counts how many times it went out of sight. const SIGHT: &str = "Sight

Hidden 0 times

\ @@ -97,6 +103,7 @@ pub async fn serve() -> SocketAddr { .route("/links", get(|| async { Html(POPUP) })) .route("/login", get(|| async { Html(LOGIN) })) .route("/size", get(|| async { Html(SIZE) })) + .route("/tall", get(|| async { Html(TALL) })) .route("/sight", get(|| async { Html(SIGHT) })) .route("/letters", get(|| async { Html(LETTERS) })) .route("/signals", get(|| async { Html(SIGNALS) })) diff --git a/docs/spec.md b/docs/spec.md index 65c83e74..985a4b26 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1086,7 +1086,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | Expansão `${VAR}` em headers do `mcp.json` | 10 | Confirmado (`mcp`); alguns nomes de credencial conhecidos são lidos vazios, `BOTLOFT_BOT_TOKEN` não é um deles. **Testado com 2.1.284**: o header chegou com o valor da variável de ambiente | feito (M3) | | Revisão do MCP que o Claude Code usa | 10 | Especificação MCP 2026-07-28 (sem `initialize`) e versões antigas. **Visto com 2.1.284**: manda `server/discover` com os headers de 2026-07-28 e, se falhar, `initialize` com 2025-11-25; o mesmo num servidor stdio | feito (M3) | | Sintaxe de caminho Windows em permission rules | 7.5 | Confirmado (`permissions`) e **testado com 2.1.283** (interativo) e **2.1.284** (`-p --setting-sources project,local`): `Read(//c/.../**)` em `deny` bloqueou a leitura com "File is in a directory that is denied by your permission settings", sem perguntar, e o `result` listou a negação em `permission_denials` | feito (M4.1) | -| Edge sem janela controlado por CDP | 21.2, 21.3 | **Visto com o Edge 154** (Windows 11): `--headless=new --remote-debugging-port=0` escreve `DevToolsActivePort` em ~400 ms; `Target.setAutoAttach` no alvo do navegador prende a aba que já existe e cada popup (`openerId`, esperando o depurador); `Emulation.setDeviceMetricsOverride` deixa a tela em 1280 × 800, e chamado de novo com a página aberta muda o `innerHeight` dela na hora; `Page.startScreencast` manda quadros JPEG com o tamanho da tela, também depois dessa mudança (com `maxHeight` no limite de 21.3), mas o quadro do tamanho novo se perde quando há quadros anteriores ainda sem `screencastFrameAck`, e uma página parada não manda outro: o daemon espera esses serem confirmados e recomeça o screencast (`stopScreencast`, `startScreencast`), que então manda um quadro; clique por `Input.dispatchMouseEvent` segue links; `alert` chega em `Page.javascriptDialogOpening`. `Target.targetInfoChanged` chega com o endereço novo da aba, mas com o endereço no lugar do título, e não vem de novo quando a página ganha o título. Ler o título com `Target.getTargetInfo` depois do `Page.loadEventFired` falhou de vez em quando nos testes (ficava o endereço); o `document.title` lido na página, num mundo isolado, não falhou. `Target.createTarget` com `about:blank` abre uma aba que chega por `attachedToTarget` como as outras; `Target.activateTarget` numa aba que ficou atrás a traz de volta, e o screencast dela volta a mandar quadros. Um Edge parado numa página que se mexe (um timer ocupado e uma animação) gastou 84% de um núcleo sem ninguém assistindo e 126% com o screencast, em ~450 MB e 7 a 10 processos. `Browser.setWindowBounds` com `windowState: "minimized"` na janela do `Browser.getWindowForTarget` deixou a página `hidden`, com o timer a menos de um por segundo e o processador abaixo de 1%; `normal` a trouxe de volta inteira, e `Page.captureScreenshot` funcionou em seguida. `Page.setWebLifecycleState` com `frozen` também parou a página, mas `active` a deixou `hidden` e lenta, e um `Page.bringToFront` depois disso travou o `Runtime.evaluate`: não serve. `Emulation.setCPUThrottlingRate` gastou mais, não menos. O user agent traz `HeadlessChrome`. Num popup esperando o depurador, `Page.enable` não responde até `Runtime.runIfWaitingForDebugger`; um mundo isolado com o mesmo nome (`Page.createIsolatedWorld`) volta o mesmo contexto até a página navegar, não enxerga os globais da página e lê `iframe` do mesmo site | feito | +| Edge sem janela controlado por CDP | 21.2, 21.3 | **Visto com o Edge 154** (Windows 11): `--headless=new --remote-debugging-port=0` escreve `DevToolsActivePort` em ~400 ms; `Target.setAutoAttach` no alvo do navegador prende a aba que já existe e cada popup (`openerId`, esperando o depurador); `Emulation.setDeviceMetricsOverride` deixa a tela em 1280 × 800, e chamado de novo com a página aberta muda o `innerHeight` dela na hora; `Page.startScreencast` manda quadros JPEG com o tamanho da tela, também depois dessa mudança (com `maxHeight` no limite de 21.3), mas o quadro do tamanho novo se perde quando há quadros anteriores ainda sem `screencastFrameAck`, e uma página parada não manda outro: o daemon espera esses serem confirmados e recomeça o screencast (`stopScreencast`, `startScreencast`), que então manda um quadro; clique por `Input.dispatchMouseEvent` segue links; `alert` chega em `Page.javascriptDialogOpening`. `Target.targetInfoChanged` chega com o endereço novo da aba, mas com o endereço no lugar do título, e não vem de novo quando a página ganha o título. Ler o título com `Target.getTargetInfo` depois do `Page.loadEventFired` falhou de vez em quando nos testes (ficava o endereço); o `document.title` lido na página, num mundo isolado, não falhou. `Target.createTarget` com `about:blank` abre uma aba que chega por `attachedToTarget` como as outras; `Target.activateTarget` numa aba que ficou atrás a traz de volta, e o screencast dela volta a mandar quadros. Um Edge parado numa página que se mexe (um timer ocupado e uma animação) gastou 84% de um núcleo sem ninguém assistindo e 126% com o screencast, em ~450 MB e 7 a 10 processos. `Browser.setWindowBounds` com `windowState: "minimized"` na janela do `Browser.getWindowForTarget` deixou a página `hidden`, com o timer a menos de um por segundo e o processador abaixo de 1%; `normal` a trouxe de volta inteira, e `Page.captureScreenshot` funcionou em seguida. `Page.setWebLifecycleState` com `frozen` também parou a página, mas `active` a deixou `hidden` e lenta, e um `Page.bringToFront` depois disso travou o `Runtime.evaluate`: não serve. `Emulation.setCPUThrottlingRate` gastou mais, não menos. O user agent traz `HeadlessChrome`. Num popup esperando o depurador, `Page.enable` não responde até `Runtime.runIfWaitingForDebugger`; um mundo isolado com o mesmo nome (`Page.createIsolatedWorld`) volta o mesmo contexto até a página navegar, não enxerga os globais da página e lê `iframe` do mesmo site | feito Nitidez (visto com o Edge do Windows 11, outubro de 2026): com `deviceScaleFactor` 2 a página diz `devicePixelRatio` 2, mas os quadros do `Page.startScreencast` continuam nos pixels da página (1000 × 700 para uma página de 1000 × 700), com `maxWidth` folgado e até com o `scale` do `setDeviceMetricsOverride`; o `Page.captureScreenshot` vem com os pixels da tela (2000 × 1400). Com `clip` (`x`/`y` de `cssVisualViewport.pageX/pageY`, o tamanho da página e `scale` 0,5), ele volta a 1000 × 700 e mostra a parte para onde a página está rolada. | | O que um site vê do Edge sem janela | 21.3 | **Visto com o Edge 154** (Windows 11), com uma página que mostra os sinais: sem nada, `navigator.webdriver` é `true`, o user agent traz `HeadlessChrome` e `outerWidth` e `outerHeight` são 0. `Emulation.setUserAgentOverride` só com `userAgent` deixa `navigator.userAgentData.brands` vazio; com `userAgentMetadata` (o que `getHighEntropyValues` devolve, com os mesmos nomes), as marcas voltam. `about:blank` não tem `navigator.userAgentData` (não é página segura); `chrome://version` tem. `--user-agent` na linha de comando mantém as marcas, mas esvazia `fullVersionList`, `architecture` e `platformVersion`. `--disable-blink-features=AutomationControlled` deixa `navigator.webdriver` `false` mesmo com `--remote-debugging-port`. `--window-size` deixa a janela com o tamanho pedido (`Browser.getWindowForTarget`), mas `outerWidth` continua 0. O login do Google recusa o navegador ("Esse navegador ou app pode não ser seguro"), também com o dono no controle | feito | | Login numa janela de verdade | 21.11 | **Visto com o Edge 154** (Windows 11): `msedge.exe` com `--user-data-dir` e sem `--remote-debugging-port` fica vivo como o próprio navegador e sai alguns segundos depois de a janela fechar. Um cookie gravado nessa janela aparece no Edge sem janela aberto depois no mesmo perfil. **A verificar** com o Claude Code real: o dono entra numa conta do Google pela janela, fecha, e o bot abre o Gmail já dentro da conta; o bot que pediu ajuda lê que o navegador fechou e abre a página de novo | manual (PR) | | Imagem no resultado de uma tool MCP | 21.4 | Resultado com `{"type": "image", "data", "mimeType"}` em `content` (especificação MCP). **Testado com 2.1.284** (Haiku): pedido só pelas cores do logo no screenshot, o bot respondeu certo, e as cores não estavam no texto da página: o modelo recebe a imagem | feito | @@ -1301,7 +1301,7 @@ Cada bot tem um navegador próprio para pesquisar e usar sites: um Microsoft Edg ### 21.3 Página - **Abas:** o daemon se prende a toda página nova do navegador (`Target.setAutoAttach` no alvo do navegador, com `flatten` e `waitForDebuggerOnStart`) e a configura antes de ela rodar. Os comandos de preparo saem juntos com `Runtime.runIfWaitingForDebugger`, sem esperar resposta um a um: numa aba aberta pela página, `Page.enable` só responde depois que ela roda (visto com o Edge 154). O navegador só conta como aberto, e uma aba nova do dono só recebe um endereço, depois desse preparo: uma página aberta antes carregaria sem o daemon ver os eventos dela. Uma aba aberta pela página (`target=_blank`, `window.open`) vira a aba ativa, como para quem usa o navegador; quando ela fecha, a que era ativa antes dela volta (`Target.activateTarget`, para o navegador mostrar a mesma que o daemon). No máximo 6 abas: acima disso, fecha a que foi ativa há mais tempo. As tools agem sempre na ativa, e o bot não tem tool para trocar de aba; o dono, no controle, troca de aba, abre uma nova e vai a um endereço (21.10). O título de cada aba, para a lista que o dono vê (21.7), é o `document.title`, lido no mundo isolado (21.6) a cada carregamento e a cada mudança de endereço dentro da página: o Edge sem janela avisa quando a aba ganha um endereço, mas nunca o título dela (19). Até a página carregar, a aba fica sem título; um título que a página troca depois disso só aparece no carregamento seguinte. -- **Tela:** 1280 × 800 px, escala 1 (`Emulation.setDeviceMetricsOverride`), enquanto ninguém assiste. Com o painel aberto, a página toma o espaço que ela tem na mesa do painel (`browser.resize`, 21.7), medido onde ela aparece, por dentro da borda da mesa, já sem os avisos e os botões em volta: assim ela o ocupa inteiro, sem faixas da mesa dos lados. Um aviso que aparece ou some (o pedido de ajuda, as notas do controle) muda esse espaço, e a página acompanha. A largura do espaço, entre 800 e 1600 px, para a página aparecer quase no tamanho do app e o dono enxergar e mexer nela, e a altura na mesma proporção do espaço, entre 600 e 2000 px, para a tela ao vivo ocupar o painel em vez de deixar um vazio. Abaixo de 800 px a maioria dos sites troca a versão de computador pela de celular: um painel mais estreito que isso encolhe a imagem, não a página. Alargar o painel ou ocupar todo o espaço dá mais página. O tamanho vale para todas as abas, e volta a 1280 × 800 quando o último que assistia para. O que a página vê do navegador é o de um Edge comum, porque alguns sites recusam o login num navegador que um programa controla (19): o user agent é o do Edge sem a palavra `Headless`, com as marcas e a plataforma de `navigator.userAgentData` (`userAgentMetadata`, lidas uma vez ao abrir, numa página `chrome://version` que fecha em seguida: `about:blank` não tem `userAgentData`), e `navigator.webdriver` é `false` (`--disable-blink-features=AutomationControlled`). O disfarce para aí: nada de script injetado na página, e `outerWidth` e `outerHeight` continuam 0. O login de quem olha mais que isso, como o do Google, se faz numa janela de verdade (21.11). +- **Tela:** 1280 × 800 px, escala 1 (`Emulation.setDeviceMetricsOverride`), enquanto ninguém assiste. Com o painel aberto, a página toma o espaço que ela tem na mesa do painel (`browser.resize`, 21.7), medido onde ela aparece, por dentro da borda da mesa, já sem os avisos e os botões em volta: assim ela o ocupa inteiro, sem faixas da mesa dos lados. Um aviso que aparece ou some (o pedido de ajuda, as notas do controle) muda esse espaço, e a página acompanha. A largura do espaço, entre 800 e 1600 px, para a página aparecer quase no tamanho do app e o dono enxergar e mexer nela, e a altura na mesma proporção do espaço, entre 600 e 2000 px, para a tela ao vivo ocupar o painel em vez de deixar um vazio. Abaixo de 800 px a maioria dos sites troca a versão de computador pela de celular: um painel mais estreito que isso encolhe a imagem, não a página. Alargar o painel ou ocupar todo o espaço dá mais página. Para o dono, a página também tem a nitidez da tela dele (`deviceScaleFactor` igual ao `devicePixelRatio` do app, de 1 a 2): numa tela com escala de 150% ou com o app ampliado, o texto não fica borrado. O tamanho da página em pixels CSS não muda com isso, e as fotos que o bot tira (`browser_screenshot`) continuam do tamanho da página: são recortadas onde a página está rolada (`Page.getLayoutMetrics`) e reduzidas (`clip.scale`), para não custarem mais ao bot. O tamanho vale para todas as abas, e volta a 1280 × 800 quando o último que assistia para. O que a página vê do navegador é o de um Edge comum, porque alguns sites recusam o login num navegador que um programa controla (19): o user agent é o do Edge sem a palavra `Headless`, com as marcas e a plataforma de `navigator.userAgentData` (`userAgentMetadata`, lidas uma vez ao abrir, numa página `chrome://version` que fecha em seguida: `about:blank` não tem `userAgentData`), e `navigator.webdriver` é `false` (`--disable-blink-features=AutomationControlled`). O disfarce para aí: nada de script injetado na página, e `outerWidth` e `outerHeight` continuam 0. O login de quem olha mais que isso, como o do Google, se faz numa janela de verdade (21.11). - **Diálogos** (`alert`, `confirm`, `prompt`, "sair da página?"): aceitos na hora; a resposta seguinte da tool diz ao bot o texto que apareceu. - **Escolher arquivo:** o pedido da página é interceptado e cancelado, e o bot lê que enviar arquivos pelo navegador ainda não é possível. - **Downloads:** vão para `\downloads\` (`Browser.setDownloadBehavior`) e aparecem no painel de arquivos (8.4); a tool diz quando um download termina. @@ -1371,7 +1371,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] | `browser.list` | | `BrowserState[]`: os navegadores que não estão fechados | | `browser.watch` | `botId` | `BrowserView {state, frame}`: o estado e o último quadro. A conexão passa a receber os quadros desse bot | | `browser.unwatch` | | `null` | -| `browser.resize` | `botId`, `width`, `height` | `null`: o espaço que a página tem na mesa do painel (21.3), em pixels do app (de 1 a 10 000 cada). O daemon ajusta a altura da página à proporção desse espaço (21.3). Só da conexão que assiste esse bot; vale também com o navegador fechado, para quando ele abrir. Cada navegador tem um tamanho só: o último pedido vale | +| `browser.resize` | `botId`, `width`, `height` | `null`: o espaço que a página tem na mesa do painel (21.3), em pixels do app, e `scale` (opcional), os pixels da tela por pixel do app em porcentagem (`devicePixelRatio` × 100; sem ele, 100): a página é desenhada tão nítida, de 100 a 200 (de 1 a 10 000 cada). O daemon ajusta a altura da página à proporção desse espaço (21.3). Só da conexão que assiste esse bot; vale também com o navegador fechado, para quando ele abrir. Cada navegador tem um tamanho só: o último pedido vale | | `browser.take` | `botId` | `BrowserState`: o dono assume o navegador (21.10). A conexão precisa estar assistindo esse bot, e o navegador aberto | | `browser.release` | `botId` | `BrowserState`: devolve o navegador ao bot; um pedido de ajuda aberto recebe **Pronto** | | `browser.input` | `botId`, `input` | `null`: um evento do dono na página. Só da conexão que controla | @@ -1386,7 +1386,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] - `BrowserState`: `botId`, `status` (`closed`, `starting`, `open`, `failed`), `url`, `title` e `loading` (da aba ativa), `tabs` (as abas abertas, na ordem em que abriram: `BrowserTab {id, title, url, active}`, com `id` opaco), `error` (por que não abriu, em `failed`), `control` (`bot` ou `owner`, 21.10), `resting` (o navegador descansa, 21.2), `ask` (o que o bot pediu ao dono com `browser_ask_owner`, enquanto o pedido está aberto), `window` (aberto numa janela própria, 21.11: o estado é `closed` e `control` é `owner`) e `updatedAt`. `browser.list` traz também os `closed` com `window`. - Notificações para todos: `browser.changed` (`BrowserState`) e `browser.action {botId, kind, x, y, label, at}`, com `kind` `open`, `click`, `type`, `select`, `press`, `scroll` ou `back`; `x` e `y` em pixels da página (o tamanho que vem em `browser.frame`) quando a ação tem um ponto; `label` é o nome do elemento, a tecla ou o site, nunca o texto digitado. Clicar, digitar e escolher avisam antes de agir: o daemon acha o elemento e o traz para a tela, manda `browser.action` com o ponto e, se alguém assiste, espera o cursor deslizar até lá no painel (560 ms, o tempo da animação mais uma folga) antes do clique de verdade; sem ninguém assistindo, não espera. As outras ações avisam depois de feitas. Assim a página muda depois que o cursor chega, e não antes. - Só para a conexão que assiste: `browser.frame {botId, data, width, height}`, um JPEG em base64 e o tamanho da página que ele mostra. Cada conexão assiste um bot por vez; `browser.watch` de outro troca, e fechar a conexão para. -- Os quadros vêm do `Page.startScreencast` (JPEG, qualidade 60, do tamanho da página, 21.3), que roda só enquanto alguém assiste, e só quando a tela muda. O daemon confirma cada quadro no máximo ~15 vezes por segundo. Um app lento não acumula quadros: a conexão manda sempre o mais novo quando consegue, e quadros não contam no limite de 1024 notificações (11.1). +- Os quadros vêm do `Page.startScreencast` (JPEG, qualidade 60, do tamanho da página, 21.3), que roda só enquanto alguém assiste, e só quando a tela muda. O daemon confirma cada quadro no máximo ~15 vezes por segundo enquanto o dono assiste o bot, e ~30 com o navegador nas mãos do dono (21.10), para a página acompanhar quando ele rola e aponta. A página é desenhada tão nítida quanto a tela do dono (`scale` de `browser.resize`, até 2×, 21.3). O screencast desenha nos pixels da própria página, por mais nítida que ela esteja (19): numa tela mais nítida, o quadro dele vem pequeno (64 px) e só avisa que a página mudou, e o quadro que vai para o app é um `Page.captureScreenshot` (JPEG, qualidade 60, `optimizeForSpeed`), que tem os pixels da tela; a confirmação só sai depois dele, para as fotos não se acumularem. Com o navegador em descanso, a foto que fica também é nítida. Um app lento não acumula quadros: a conexão manda sempre o mais novo quando consegue, e quadros não contam no limite de 1024 notificações (11.1). ### 21.8 App (N2)