Skip to content

Commit a3c53fe

Browse files
authored
Merge pull request #167 from httpsphl/feat/browser-sharp
feat(browser): the owner sees the page as sharp as their screen, and quicker in their hands
2 parents 2d737e9 + 386a7f1 commit a3c53fe

16 files changed

Lines changed: 256 additions & 58 deletions

File tree

‎app/src/features/browser/browser.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ describe("browser panel", () => {
8282
fireEvent.click(toggle());
8383
const resized = () =>
8484
fake.calls.filter((call) => call.method === "browser.resize").map((call) => call.params);
85-
await waitFor(() => expect(resized()).toEqual([{ botId: scout.id, width: 560, height: 804 }]));
85+
await waitFor(() =>
86+
expect(resized()).toEqual([{ botId: scout.id, width: 560, height: 804, scale: 100 }]),
87+
);
8688
// A narrow room keeps the desktop layout's width, in the room's shape.
8789
expect(fake.browser.size(scout.id)).toEqual({ width: 800, height: 1148 });
8890

‎app/src/features/browser/useRoom.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,21 +49,24 @@ export function useRoom(): [Size | null, (node: HTMLElement | null) => void] {
4949

5050
/**
5151
* Tells the daemon the room the page has, once it stops changing, while
52-
* this connection watches the bot's browser.
52+
* this connection watches the bot's browser, and how sharp the screen is,
53+
* so the page is drawn with as many pixels as show it (spec 21.3).
5354
*/
5455
export function useFitPage(bot: Pick<Bot, "id">, room: Size | null, watched: boolean): void {
5556
const api = useApi();
5657
const known = room !== null;
5758
const width = Math.max(1, room?.width ?? 0);
5859
const height = Math.max(1, room?.height ?? 0);
60+
// A change of the app's zoom changes the room too, which reads it again.
61+
const scale = Math.round((typeof devicePixelRatio === "number" ? devicePixelRatio : 1) * 100);
5962
useEffect(() => {
6063
if (!watched || !known) {
6164
return;
6265
}
6366
const timer = setTimeout(() => {
6467
// The next change of the room asks again.
65-
api.call("browser.resize", { botId: bot.id, width, height }).catch(() => {});
68+
api.call("browser.resize", { botId: bot.id, width, height, scale }).catch(() => {});
6669
}, SETTLED_MS);
6770
return () => clearTimeout(timer);
68-
}, [api, bot.id, watched, known, width, height]);
71+
}, [api, bot.id, watched, known, width, height, scale]);
6972
}

‎app/src/lib/protocol.gen.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1340,7 +1340,13 @@ export type BrowserWatchParams = { botId: BotId, };
13401340
* `browser.resize`: the room the app's panel has for the page, in the
13411341
* app's pixels. The page takes its shape (spec 21.3).
13421342
*/
1343-
export type BrowserResizeParams = { botId: BotId, width: number, height: number, };
1343+
export type BrowserResizeParams = { botId: BotId, width: number, height: number,
1344+
/**
1345+
* The screen's pixels per pixel of the app, in percent (its
1346+
* `devicePixelRatio` times 100): the page is drawn that sharp for the
1347+
* owner, up to twice (spec 21.3). Absent, 100.
1348+
*/
1349+
scale?: number, };
13441350

13451351
export type MouseAction = "move" | "down" | "up";
13461352

‎crates/botloft-core/src/protocol/browser.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,4 +166,10 @@ pub struct BrowserResizeParams {
166166
pub bot_id: BotId,
167167
pub width: u32,
168168
pub height: u32,
169+
/// The screen's pixels per pixel of the app, in percent (its
170+
/// `devicePixelRatio` times 100): the page is drawn that sharp for the
171+
/// owner, up to twice (spec 21.3). Absent, 100.
172+
#[serde(default, skip_serializing_if = "Option::is_none")]
173+
#[cfg_attr(test, ts(optional))]
174+
pub scale: Option<u32>,
169175
}

‎crates/botloftd/src/browser/events.rs‎

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use tokio::sync::mpsc;
1111
use tracing::debug;
1212

1313
use super::cdp::CdpEvent;
14-
use super::session::{FRAME_GAP, Hooks, MAX_TABS, PageInfo, Session, Tab};
14+
use super::session::{FRAME_GAP, Hooks, MAX_TABS, PageInfo, QUICK_GAP, Session, Tab};
1515
use super::titles::retitles;
1616

1717
impl Session {
@@ -196,29 +196,60 @@ impl Session {
196196
}
197197

198198
fn frame(&self, session: &str, params: &Value, hooks: &Hooks) {
199-
let (active, viewport) = {
199+
let (active, viewport, gap) = {
200200
let tabs = self.lock();
201201
let active = tabs.active().is_some_and(|tab| tab.session == session);
202-
(active, tabs.viewport)
202+
let gap = if tabs.quick { QUICK_GAP } else { FRAME_GAP };
203+
(active, tabs.viewport, gap)
203204
};
204-
if active && let Some(data) = params["data"].as_str() {
205-
let size = |key: &str, fallback: u32| {
206-
params["metadata"][key]
207-
.as_f64()
208-
.map_or(fallback, |value| value.round() as u32)
209-
};
205+
let size = |key: &str, fallback: u32| {
206+
params["metadata"][key]
207+
.as_f64()
208+
.map_or(fallback, |value| value.round() as u32)
209+
};
210+
let (width, height) = (
211+
size("deviceWidth", viewport.width),
212+
size("deviceHeight", viewport.height),
213+
);
214+
// The screencast draws in the page's own pixels, however sharp the
215+
// page is (spec 19): on a sharper screen its frame only says the page
216+
// changed, and the frame sent is a screenshot, which is that sharp.
217+
let sharp = active && viewport.scale > 100;
218+
if active
219+
&& !sharp
220+
&& let Some(data) = params["data"].as_str()
221+
{
210222
hooks.frames.send_replace(Some(Arc::new(BrowserFrame {
211223
bot_id: hooks.bot.clone(),
212224
data: data.to_owned(),
213-
width: size("deviceWidth", viewport.width),
214-
height: size("deviceHeight", viewport.height),
225+
width,
226+
height,
215227
})));
216228
}
217229
let cdp = self.cdp.clone();
218230
let session = session.to_owned();
219231
let ack = json!({ "sessionId": params["sessionId"] });
232+
let (frames, bot) = (hooks.frames.clone(), hooks.bot.clone());
220233
tokio::spawn(async move {
221-
tokio::time::sleep(FRAME_GAP).await;
234+
if sharp {
235+
let shot = json!({ "format": "jpeg", "quality": 60, "optimizeForSpeed": true });
236+
let taken = cdp
237+
.call(Some(&session), "Page.captureScreenshot", shot)
238+
.await;
239+
if let Some(data) = taken
240+
.ok()
241+
.and_then(|shot| shot["data"].as_str().map(str::to_owned))
242+
{
243+
frames.send_replace(Some(Arc::new(BrowserFrame {
244+
bot_id: bot,
245+
data,
246+
width,
247+
height,
248+
})));
249+
}
250+
}
251+
// Confirmed only after, so screenshots never pile up.
252+
tokio::time::sleep(gap).await;
222253
cdp.send(Some(&session), "Page.screencastFrameAck", ack);
223254
});
224255
}

‎crates/botloftd/src/browser/hands.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ impl Drop for Hands {
281281
mine
282282
});
283283
if released {
284+
self.session.owner_gave_back();
284285
update(
285286
&self.slots,
286287
&self.events,

‎crates/botloftd/src/browser/moves.rs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,14 +136,21 @@ impl Session {
136136
Ok(())
137137
}
138138

139-
/// The owner took the browser: remember the tab the bot was on.
139+
/// The owner took the browser: remember the tab the bot was on, and
140+
/// send them frames quicker.
140141
pub(super) fn owner_took(&self) {
141142
let mut tabs = self.lock();
143+
tabs.quick = true;
142144
if tabs.owner_from.is_none() {
143145
tabs.owner_from = tabs.active().map(|tab| tab.target.clone());
144146
}
145147
}
146148

149+
/// The owner gave the browser back: frames as for watching the bot.
150+
pub(super) fn owner_gave_back(&self) {
151+
self.lock().quick = false;
152+
}
153+
147154
/// Whether the owner had the browser and left the bot on another tab
148155
/// than the one it was on; asked once, by the bot's next tool (spec
149156
/// 21.10).

‎crates/botloftd/src/browser/page.rs‎

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -294,21 +294,4 @@ impl Session {
294294
self.settle(marks).await;
295295
Ok(true)
296296
}
297-
298-
/// The screen as a JPEG, base64.
299-
pub async fn screenshot(&self) -> Result<String, BrowserError> {
300-
let (session, _) = self.page()?;
301-
let shot = self
302-
.cdp
303-
.call(
304-
Some(&session),
305-
"Page.captureScreenshot",
306-
json!({ "format": "jpeg", "quality": 70 }),
307-
)
308-
.await?;
309-
shot["data"]
310-
.as_str()
311-
.map(str::to_owned)
312-
.ok_or_else(|| BrowserError::Page("the browser returned no picture".to_owned()))
313-
}
314297
}

‎crates/botloftd/src/browser/rest.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ impl Browsers {
123123
if held || session.is_resting() {
124124
return;
125125
}
126-
if let Ok(data) = session.screenshot().await {
126+
if let Ok(data) = session.picture().await {
127127
let page = session.viewport();
128128
frames.send_replace(Some(Arc::new(BrowserFrame {
129129
bot_id: bot.clone(),

‎crates/botloftd/src/browser/session.rs‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,14 @@ use super::{BrowserError, agent};
2121

2222
/// Most tabs a browser keeps; past this, the one active longest ago closes.
2323
pub(super) const MAX_TABS: usize = 6;
24-
/// Frames are confirmed after this, so the browser sends ~15 a second.
24+
/// Frames are confirmed after this, so the browser sends ~15 a second
25+
/// while the owner watches the bot work...
2526
pub(super) const FRAME_GAP: Duration = Duration::from_millis(66);
27+
/// ...and ~30 while the browser is in the owner's hands, so the page keeps
28+
/// up with them as they scroll and point (spec 21.8).
29+
pub(super) const QUICK_GAP: Duration = Duration::from_millis(33);
30+
/// The largest side of a frame that only says the page changed.
31+
const SIGNAL: u32 = 64;
2632
/// How long the first tab may take to show up.
2733
const FIRST_TAB_TIMEOUT: Duration = Duration::from_secs(5);
2834

@@ -76,6 +82,8 @@ pub struct Tabs {
7682
pub(super) resting: bool,
7783
pub closed: bool,
7884
pub viewport: Viewport,
85+
/// The owner has it in their hands: frames come quicker.
86+
pub(super) quick: bool,
7987
/// The tab the bot was on when the owner took the browser, until the
8088
/// bot's next tool (spec 21.10).
8189
pub(super) owner_from: Option<String>,
@@ -251,9 +259,16 @@ impl Session {
251259

252260
pub(super) async fn cast(&self, session: &str, on: bool) {
253261
let result = if on {
254-
// Frames come the size of the page, however tall it is.
262+
// Frames come the size of the page, however tall it is. On a
263+
// sharper screen they only say the page changed (`events.rs`),
264+
// so they come small.
265+
let (max_width, max_height) = if self.viewport().scale > 100 {
266+
(SIGNAL, SIGNAL)
267+
} else {
268+
(MAX_WIDTH, MAX_HEIGHT)
269+
};
255270
let params = json!({
256-
"format": "jpeg", "quality": 60, "maxWidth": MAX_WIDTH, "maxHeight": MAX_HEIGHT,
271+
"format": "jpeg", "quality": 60, "maxWidth": max_width, "maxHeight": max_height,
257272
});
258273
self.cdp
259274
.call(Some(session), "Page.startScreencast", params)

0 commit comments

Comments
 (0)