diff --git a/app/src/dev/seedBrowser.ts b/app/src/dev/seedBrowser.ts
index e968486d..c8168c58 100644
--- a/app/src/dev/seedBrowser.ts
+++ b/app/src/dev/seedBrowser.ts
@@ -1,12 +1,13 @@
// Dev only: Scout searching in its browser in the fake preview, so the
// browser panel has something live to show. The "page" is drawn on a
-// canvas and sent as JPEG frames, like the daemon's screencast.
+// canvas, the size the panel asks for, and sent as JPEG frames, like the
+// daemon's screencast.
import type { FakeBotloft } from "../lib/fake";
+import type { PageSize } from "../lib/fakeBrowser";
import type { BotId } from "../lib/protocol.gen";
-const W = 1280;
-const H = 800;
+const RECIPES = ["Classic sourdough loaf", "Starter in 5 days", "No-knead rye", "Focaccia"];
interface Scene {
url: string;
@@ -15,16 +16,16 @@ interface Scene {
results: boolean;
}
-function draw(scene: Scene): string {
+function draw(scene: Scene, size: PageSize): string {
const canvas = document.createElement("canvas");
- canvas.width = W;
- canvas.height = H;
+ canvas.width = size.width;
+ canvas.height = size.height;
const g = canvas.getContext("2d");
if (!g) {
return "";
}
g.fillStyle = "#ffffff";
- g.fillRect(0, 0, W, H);
+ g.fillRect(0, 0, size.width, size.height);
g.fillStyle = "#1f2937";
g.font = "600 34px system-ui, sans-serif";
g.fillText(scene.results ? "Results" : "Find a recipe", 120, 150);
@@ -43,15 +44,12 @@ function draw(scene: Scene): string {
g.fillStyle = "#ffffff";
g.fillText("Search", 938, 226);
if (scene.results) {
- for (let row = 0; row < 4; row++) {
+ // A taller page shows more of the list.
+ for (let row = 0; 300 + row * 110 < size.height - 60; row++) {
const y = 300 + row * 110;
g.fillStyle = "#1d4ed8";
g.font = "600 24px system-ui, sans-serif";
- g.fillText(
- ["Classic sourdough loaf", "Starter in 5 days", "No-knead rye", "Focaccia"][row] ?? "",
- 120,
- y,
- );
+ g.fillText(RECIPES[row % RECIPES.length] ?? "", 120, y);
g.fillStyle = "#e5e7eb";
g.fillRect(120, y + 18, 820, 14);
g.fillRect(120, y + 42, 560, 14);
@@ -117,14 +115,14 @@ export function seedBrowser(fake: FakeBotloft, scout: BotId): void {
let scene = home;
let step = 0;
fake.browser.open(scout, home.url, home.title);
- fake.browser.frame(scout, draw(scene));
+ fake.browser.paint(scout, (size) => draw(scene, size));
setInterval(() => {
const next = steps[step % steps.length];
step += 1;
if (next) {
scene = next(scene);
fake.browser.open(scout, scene.url, scene.title);
- fake.browser.frame(scout, draw(scene));
+ fake.browser.repaint(scout);
}
}, 2200);
}
diff --git a/app/src/dev/seedHands.ts b/app/src/dev/seedHands.ts
index e55c910a..8d955e06 100644
--- a/app/src/dev/seedHands.ts
+++ b/app/src/dev/seedHands.ts
@@ -3,10 +3,9 @@
// the owner's clicks and keys, so taking the browser can be tried end to end.
import type { FakeBotloft } from "../lib/fake";
+import type { PageSize } from "../lib/fakeBrowser";
import type { BrowserInput, CrewId } from "../lib/protocol.gen";
-const W = 1280;
-const H = 800;
const FIELDS = { user: 300, password: 390 } as const;
type Field = keyof typeof FIELDS;
@@ -17,16 +16,16 @@ interface Form {
signedIn: boolean;
}
-function draw(form: Form): string {
+function draw(form: Form, size: PageSize): string {
const canvas = document.createElement("canvas");
- canvas.width = W;
- canvas.height = H;
+ canvas.width = size.width;
+ canvas.height = size.height;
const g = canvas.getContext("2d");
if (!g) {
return "";
}
g.fillStyle = "#f7f4ee";
- g.fillRect(0, 0, W, H);
+ g.fillRect(0, 0, size.width, size.height);
g.fillStyle = "#3b2a1a";
g.font = "600 36px Georgia, serif";
if (form.signedIn) {
@@ -92,7 +91,7 @@ export function seedHands(fake: FakeBotloft, crewId: CrewId): void {
const form: Form = { user: "", password: "", field: null, signedIn: false };
const login = "https://flour.example/login";
fake.browser.open(clerk.id, login, "Sign in · Flour Co.");
- fake.browser.frame(clerk.id, draw(form));
+ fake.browser.paint(clerk.id, (size) => draw(form, size));
fake.chat.tool(clerk.id, "mcp__botloft__browser_open", { summary: login, status: "done" });
const task = "Sign in to Flour Co. with the bakery's account";
fake.chat.tool(clerk.id, "mcp__botloft__browser_ask_owner", { summary: task, status: "running" });
@@ -105,6 +104,6 @@ export function seedHands(fake: FakeBotloft, crewId: CrewId): void {
form.signedIn = true;
fake.browser.open(clerk.id, "https://flour.example/orders", "Orders · Flour Co.");
}
- fake.browser.frame(clerk.id, draw(form));
+ fake.browser.repaint(clerk.id);
});
}
diff --git a/app/src/features/browser/BrowserPanel.tsx b/app/src/features/browser/BrowserPanel.tsx
index 4c5e60ea..f121eda8 100644
--- a/app/src/features/browser/BrowserPanel.tsx
+++ b/app/src/features/browser/BrowserPanel.tsx
@@ -18,6 +18,12 @@ import { HandsLayer } from "./HandsLayer";
import { LiveView, useCaption } from "./LiveView";
import { useBrowserView } from "./useBrowserView";
import { useHands } from "./useHands";
+import { useFitPage, useRoom } from "./useRoom";
+
+/** Around the page in the panel, in px. */
+const PAD = 12;
+/** Kept under the page, for what the bot just did and the button to take it. */
+const UNDER = 80;
export function BrowserPanel({
bot,
@@ -39,6 +45,14 @@ export function BrowserPanel({
const hands = useHands(bot, state);
const [focused, setFocused] = useState(false);
const ask = state?.ask ?? null;
+ // The page takes the shape of the room the panel has for it (spec 21.3).
+ // Notices that come and go are not counted: they never resize the page.
+ const [inside, body] = useRoom();
+ const room = inside && {
+ width: inside.width - 2 * PAD,
+ height: inside.height - 2 * PAD - UNDER,
+ };
+ useFitPage(bot, room, watched);
// Taking needs the watch first: the daemon ties the hands to it.
const taken = useRef(0);
useEffect(() => {
@@ -75,7 +89,11 @@ export function BrowserPanel({
{state && (status === "open" || status === "starting") && }
-
+
{status === "failed" ? (
{t.failedBody}
@@ -94,41 +112,44 @@ export function BrowserPanel({
<>
{live && ask && !hands.held && }
{hands.held && }
-
-
- {hands.held && frame && (
-
+
+
+ {hands.held && frame && (
+
+ )}
+
+ {(status !== "open" || !frame) && (
+
)}
-
- {(status !== "open" || !frame) && (
-
- )}
+
- {status === "open" && caption && !hands.held && (
-
-
- {caption}
+ {status === "open" && !hands.held && (
+ // Its place is kept while empty, so the page never moves for it.
+
+ {caption && (
+
+
+ {caption}
+
+ )}
)}
{live && !hands.held && !ask && }
diff --git a/app/src/features/browser/LiveView.tsx b/app/src/features/browser/LiveView.tsx
index 8c7b16a0..04b20c2d 100644
--- a/app/src/features/browser/LiveView.tsx
+++ b/app/src/features/browser/LiveView.tsx
@@ -1,12 +1,13 @@
// The bot's screen, live (spec 21.8): the newest picture of its active
-// tab, with its cursor gliding to each point it acts on, a ring where it
-// clicks, and a line about what it just did.
+// tab, as large as fits the panel, with its cursor gliding to each point it
+// acts on, a ring where it clicks, and a line about what it just did.
import { type ReactNode, useEffect, useState } from "react";
import { useT } from "../../i18n";
import type { Bot, BrowserAction, BrowserFrame } from "../../lib/protocol.gen";
import { BotCursor } from "../bots/BotCursor";
+/** The page until a picture says its size. */
const PAGE = { width: 1280, height: 800 };
/** Where on the screen, in percent, an action happened. */
@@ -81,10 +82,14 @@ export function LiveView({
return (
{frame && (
{
+ cleanup();
+ vi.restoreAllMocks();
+});
/** A crew "Ops" with @scout, idle. */
function crew() {
@@ -68,6 +71,37 @@ describe("browser panel", () => {
expect(within(panel()).queryByText("Opened elsewhere.com")).toBeNull();
});
+ test("gives the page the shape of the room the panel has, and shows it as large as fits", async () => {
+ const { fake, scout } = crew();
+ fake.browser.open(scout.id, "https://example.com/", "Example");
+ // The panel under its address row, as the app measures it: 536 by 700
+ // for the page, once what goes around and under it is set aside.
+ vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(560);
+ vi.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(804);
+ await openScout(fake);
+ 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: 536, height: 700 }]));
+ // As wide as ever, and as tall as the room's shape asks.
+ expect(fake.browser.size(scout.id)).toEqual({ width: 1280, height: 1671 });
+
+ act(() => {
+ fake.browser.frame(scout.id, "AAAA");
+ });
+ // The bot's cursor lands where it acted on the taller page.
+ const screenshot = within(panel()).getByRole("figure", { name: "What Scout sees" });
+ act(() => {
+ fake.browser.act(scout.id, "click", { x: 640, y: 1671, label: "More" });
+ });
+ const cursor = screenshot.querySelector(".bot-cursor") as HTMLElement;
+ expect(cursor.style.top).toBe("100%");
+
+ // Nobody watches anymore: the page is back to its own size.
+ fireEvent.click(within(panel()).getByRole("button", { name: "Close" }));
+ await waitFor(() => expect(fake.browser.size(scout.id)).toEqual({ width: 1280, height: 800 }));
+ });
+
test("opens by itself when the bot starts browsing, and the button marks it once closed", async () => {
const { fake, scout } = crew();
await openScout(fake);
diff --git a/app/src/features/browser/useRoom.ts b/app/src/features/browser/useRoom.ts
new file mode 100644
index 00000000..f0b56842
--- /dev/null
+++ b/app/src/features/browser/useRoom.ts
@@ -0,0 +1,69 @@
+// The room the panel has for the bot's page (spec 21.8): measured as the
+// panel changes and told to the daemon, so the page takes its shape (spec
+// 21.3).
+
+import { useEffect, useLayoutEffect, useState } from "react";
+import type { Bot } from "../../lib/protocol.gen";
+import { useApi } from "../../store/context";
+
+export interface Size {
+ width: number;
+ height: number;
+}
+
+/** How long the room stays the same before the daemon hears about it. */
+const SETTLED_MS = 300;
+
+/**
+ * The room inside an element, following it as it changes: `null` until it
+ * has any. The second value goes in the element's `ref`.
+ */
+export function useRoom(): [Size | null, (node: HTMLElement | null) => void] {
+ const [node, setNode] = useState(null);
+ const [room, setRoom] = useState(null);
+ useLayoutEffect(() => {
+ if (!node) {
+ setRoom(null);
+ return;
+ }
+ const measure = () => {
+ const width = node.clientWidth;
+ const height = node.clientHeight;
+ setRoom((was) => {
+ if (width <= 0 || height <= 0) {
+ return null;
+ }
+ return was?.width === width && was.height === height ? was : { width, height };
+ });
+ };
+ measure();
+ if (typeof ResizeObserver === "undefined") {
+ return;
+ }
+ const observer = new ResizeObserver(measure);
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [node]);
+ return [room, setNode];
+}
+
+/**
+ * Tells the daemon the room the page has, once it stops changing, while
+ * this connection watches the bot's browser.
+ */
+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);
+ 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(() => {});
+ }, SETTLED_MS);
+ return () => clearTimeout(timer);
+ }, [api, bot.id, watched, known, width, height]);
+}
diff --git a/app/src/lib/fakeBrowser.ts b/app/src/lib/fakeBrowser.ts
index 90ffedfe..af71fcd9 100644
--- a/app/src/lib/fakeBrowser.ts
+++ b/app/src/lib/fakeBrowser.ts
@@ -1,6 +1,7 @@
// The fake daemon's browsers (spec 21.7): each bot's state, the live frames
// of the one being watched and what the bot does, for the browser panel,
-// and the owner's hands in it (spec 21.10).
+// the size the panel gives the page (spec 21.3), and the owner's hands in
+// it (spec 21.10).
import type { FakeBotloft, Handlers } from "./fake";
import { conflict } from "./fakeRules";
@@ -14,6 +15,20 @@ import type {
BrowserState,
} from "./protocol.gen";
+/** The page's size: as wide as ever, as tall as the panel's room asks. */
+export interface PageSize {
+ width: number;
+ height: number;
+}
+
+const PAGE: PageSize = { width: 1280, height: 800 };
+
+/** The daemon's rule for the page in a room of the panel (spec 21.3). */
+function fitting(width: number, height: number): PageSize {
+ const tall = Math.floor((PAGE.width * height) / width);
+ return { width: PAGE.width, height: Math.min(2000, Math.max(600, tall)) };
+}
+
export class FakeBrowser {
private readonly states = new Map();
private readonly frames = new Map();
@@ -23,6 +38,10 @@ export class FakeBrowser {
readonly watches: (BotId | null)[] = [];
/** What the owner sent to the page, in order. */
readonly inputs: BrowserInput[] = [];
+ /** Each bot's page size, while the panel asks for one. */
+ private readonly sizes = new Map();
+ /** What draws each bot's page, for the preview. */
+ private readonly painters = new Map string>();
/** Each bot's open request for help. */
private readonly asks = new Map();
/** Pages that answer the owner's hands, for the preview. */
@@ -96,9 +115,28 @@ export class FakeBrowser {
this.set(botId, { ask: null, control: "bot" });
}
+ /** The size of the bot's page now. */
+ size(botId: BotId): PageSize {
+ return this.sizes.get(botId) ?? PAGE;
+ }
+
+ /** Draws the bot's page with `draw`, now and whenever its size changes. */
+ paint(botId: BotId, draw: (size: PageSize) => string): void {
+ this.painters.set(botId, draw);
+ this.repaint(botId);
+ }
+
+ /** The page changed: a new picture of it, if something draws it. */
+ repaint(botId: BotId): void {
+ const draw = this.painters.get(botId);
+ if (draw) {
+ this.frame(botId, draw(this.size(botId)));
+ }
+ }
+
/** A new picture of the page; sent only while the app watches that bot. */
- frame(botId: BotId, data: string, width = 1280, height = 800): void {
- const frame: BrowserFrame = { botId, data, width, height };
+ frame(botId: BotId, data: string, size: PageSize = this.size(botId)): void {
+ const frame: BrowserFrame = { botId, data, ...size };
this.frames.set(botId, frame);
if (this.watching === botId) {
this.fake.emit({ name: "browser.frame", params: frame });
@@ -132,11 +170,22 @@ export class FakeBrowser {
return this.set(botId, { control: "bot" });
}
+ /** Nobody watches: the page goes back to the size bots work in alone. */
+ private unwatch(): void {
+ const botId = this.watching;
+ this.release();
+ this.watching = null;
+ if (botId !== null && this.sizes.delete(botId)) {
+ this.repaint(botId);
+ }
+ }
+
handlers(): Pick<
Handlers,
| "browser.list"
| "browser.watch"
| "browser.unwatch"
+ | "browser.resize"
| "browser.take"
| "browser.release"
| "browser.input"
@@ -145,17 +194,24 @@ export class FakeBrowser {
"browser.list": () => [...this.states.values()].filter((state) => state.status !== "closed"),
"browser.watch": ({ botId }) => {
this.fake.bot(botId, false);
- this.release();
+ this.unwatch();
this.watching = botId;
this.watches.push(botId);
return { state: this.state(botId), frame: this.frames.get(botId) ?? null };
},
"browser.unwatch": () => {
- this.release();
- this.watching = null;
+ this.unwatch();
this.watches.push(null);
return null;
},
+ "browser.resize": ({ botId, width, height }) => {
+ if (this.watching !== botId) {
+ throw conflict("watch this browser before sizing it");
+ }
+ this.sizes.set(botId, fitting(width, height));
+ this.repaint(botId);
+ return null;
+ },
"browser.take": ({ botId }) => {
if (this.watching !== botId) {
throw conflict("watch this browser before taking it");
diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts
index 1aa00419..0d3f277e 100644
--- a/app/src/lib/protocol.gen.ts
+++ b/app/src/lib/protocol.gen.ts
@@ -892,6 +892,12 @@ export type BrowserView = { state: BrowserState, frame: BrowserFrame | null, };
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 MouseAction = "move" | "down" | "up";
export type MouseButton = "none" | "left" | "middle" | "right";
@@ -1020,6 +1026,7 @@ export interface RpcMethods {
"browser.list": { params: undefined; result: Array };
"browser.watch": { params: BrowserWatchParams; result: BrowserView };
"browser.unwatch": { params: undefined; result: null };
+ "browser.resize": { params: BrowserResizeParams; result: null };
"browser.take": { params: BrowserControlParams; result: BrowserState };
"browser.release": { params: BrowserControlParams; result: BrowserState };
"browser.input": { params: BrowserInputParams; result: null };
diff --git a/crates/botloft-core/src/protocol/browser.rs b/crates/botloft-core/src/protocol/browser.rs
index 194fb53d..6b839f20 100644
--- a/crates/botloft-core/src/protocol/browser.rs
+++ b/crates/botloft-core/src/protocol/browser.rs
@@ -128,3 +128,14 @@ pub struct BrowserView {
pub struct BrowserWatchParams {
pub bot_id: 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).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct BrowserResizeParams {
+ pub bot_id: BotId,
+ pub width: u32,
+ pub height: u32,
+}
diff --git a/crates/botloft-core/src/protocol/export_browser.rs b/crates/botloft-core/src/protocol/export_browser.rs
index dfc9c191..c99f4908 100644
--- a/crates/botloft-core/src/protocol/export_browser.rs
+++ b/crates/botloft-core/src/protocol/export_browser.rs
@@ -14,6 +14,7 @@ pub(super) fn decls(out: &mut Out) {
out.decl::();
out.decl::();
out.decl::();
+ out.decl::();
out.decl::();
out.decl::();
out.decl::();
@@ -37,6 +38,11 @@ pub(super) fn methods(out: &mut Out) {
&out.name::(),
);
out.method(method::BROWSER_UNWATCH, "undefined", "null");
+ out.method(
+ method::BROWSER_RESIZE,
+ &out.name::(),
+ "null",
+ );
let control = out.name::();
let state = out.name::();
out.method(method::BROWSER_TAKE, &control, &state);
diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs
index 43054c83..df1e5651 100644
--- a/crates/botloft-core/src/protocol/mod.rs
+++ b/crates/botloft-core/src/protocol/mod.rs
@@ -117,6 +117,7 @@ pub mod method {
pub const BROWSER_LIST: &str = "browser.list";
pub const BROWSER_WATCH: &str = "browser.watch";
pub const BROWSER_UNWATCH: &str = "browser.unwatch";
+ pub const BROWSER_RESIZE: &str = "browser.resize";
pub const BROWSER_TAKE: &str = "browser.take";
pub const BROWSER_RELEASE: &str = "browser.release";
pub const BROWSER_INPUT: &str = "browser.input";
diff --git a/crates/botloftd/src/browser/call.rs b/crates/botloftd/src/browser/call.rs
index 84b683fe..61092e45 100644
--- a/crates/botloftd/src/browser/call.rs
+++ b/crates/botloftd/src/browser/call.rs
@@ -11,7 +11,7 @@ use tokio::sync::OwnedMutexGuard;
use tracing::debug;
use super::session::{Hooks, PageInfo, Session};
-use super::{BrowserError, Browsers, launch, lock, update};
+use super::{BrowserError, Browsers, launch, lock, update, watch};
pub struct Call<'a> {
pub(super) browsers: &'a Browsers,
@@ -43,7 +43,11 @@ impl Call<'_> {
None => Err(BrowserError::NotFound),
Some(program) => {
let profile = browsers.profiles.join(bot.as_str());
- Session::start(&program, &profile, downloads, self.hooks(start)).await
+ let viewport = {
+ let mut slots = lock(&browsers.slots);
+ browsers.slot(&mut slots, bot).viewport
+ };
+ Session::start(&program, &profile, downloads, viewport, self.hooks(start)).await
}
};
let session = match started {
@@ -57,16 +61,14 @@ impl Call<'_> {
return Err(err);
}
};
- let watched = {
+ {
let mut slots = lock(&browsers.slots);
let slot = browsers.slot(&mut slots, bot);
slot.session = Some((start, Arc::clone(&session)));
- slot.watchers > 0
- };
- browsers.set_state(bot, |state| state.status = BrowserStatus::Open);
- if watched {
- session.set_watching(true).await;
}
+ browsers.set_state(bot, |state| state.status = BrowserStatus::Open);
+ // Whoever watches already gets frames, the size they asked for.
+ watch::sync(Arc::clone(&browsers.slots), bot.clone()).await;
debug!(bot = %bot, "browser: open");
Ok(session)
}
diff --git a/crates/botloftd/src/browser/events.rs b/crates/botloftd/src/browser/events.rs
index 675f720c..13c6d234 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, HEIGHT, Hooks, MAX_TABS, PageInfo, Session, Tab, WIDTH};
+use super::session::{FRAME_GAP, Hooks, MAX_TABS, PageInfo, Session, Tab};
impl Session {
/// Sets up a new tab before it runs, and makes it the active one.
@@ -25,7 +25,7 @@ impl Session {
else {
return;
};
- let (previous, watching, extra) = {
+ let (previous, watching, extra, viewport) = {
let mut tabs = self.lock();
let previous = tabs.active().map(|tab| tab.session.clone());
tabs.list.push(Tab {
@@ -39,7 +39,7 @@ impl Session {
network_at: Instant::now(),
});
let extra = (tabs.list.len() > MAX_TABS).then(|| tabs.list[0].target.clone());
- (previous, tabs.watching, extra)
+ (previous, tabs.watching, extra, tabs.viewport)
};
let setup = [
("Page.enable", json!({})),
@@ -48,10 +48,7 @@ impl Session {
"Page.setInterceptFileChooserDialog",
json!({ "enabled": true }),
),
- (
- "Emulation.setDeviceMetricsOverride",
- json!({ "width": WIDTH, "height": HEIGHT, "deviceScaleFactor": 1, "mobile": false }),
- ),
+ ("Emulation.setDeviceMetricsOverride", viewport.metrics()),
(
"Emulation.setUserAgentOverride",
json!({ "userAgent": self.user_agent }),
@@ -188,10 +185,11 @@ impl Session {
}
fn frame(&self, session: &str, params: &Value, hooks: &Hooks) {
- let active = self
- .lock()
- .active()
- .is_some_and(|tab| tab.session == session);
+ let (active, viewport) = {
+ let tabs = self.lock();
+ let active = tabs.active().is_some_and(|tab| tab.session == session);
+ (active, tabs.viewport)
+ };
if active && let Some(data) = params["data"].as_str() {
let size = |key: &str, fallback: u32| {
params["metadata"][key]
@@ -201,8 +199,8 @@ impl Session {
hooks.frames.send_replace(Some(Arc::new(BrowserFrame {
bot_id: hooks.bot.clone(),
data: data.to_owned(),
- width: size("deviceWidth", WIDTH),
- height: size("deviceHeight", HEIGHT),
+ width: size("deviceWidth", viewport.width),
+ height: size("deviceHeight", viewport.height),
})));
}
let cdp = self.cdp.clone();
diff --git a/crates/botloftd/src/browser/hands.rs b/crates/botloftd/src/browser/hands.rs
index 34a8f932..7d3d3a88 100644
--- a/crates/botloftd/src/browser/hands.rs
+++ b/crates/botloftd/src/browser/hands.rs
@@ -11,8 +11,8 @@ use botloft_core::protocol::{BrowserControl, BrowserInput, MouseAction};
use tokio::sync::{broadcast, mpsc};
use tracing::debug;
-use super::session::{HEIGHT, Session, WIDTH};
-use super::{Browsers, Slots, lock, update};
+use super::session::Session;
+use super::{Browsers, Slots, Viewport, lock, update};
use crate::clock::Clock;
use crate::state::Event;
@@ -145,14 +145,15 @@ impl Drop for Asking<'_> {
impl Hands {
/// Sends one of the owner's events to the page, after the ones before.
pub fn send(&self, input: BrowserInput) -> Result<(), InputError> {
- if !fits(&input) {
- return Err(InputError::Invalid);
- }
- let holds = lock(&self.slots)
+ let page = lock(&self.slots)
.get(&self.bot)
- .is_some_and(|slot| *slot.held.borrow() == Some(self.hold));
- if !holds {
+ .filter(|slot| *slot.held.borrow() == Some(self.hold))
+ .map(|slot| slot.viewport);
+ let Some(page) = page else {
return Err(InputError::NotHeld);
+ };
+ if !fits(&input, page) {
+ return Err(InputError::Invalid);
}
self.input.send(input).map_err(|_| InputError::Closed)
}
@@ -180,11 +181,9 @@ impl Drop for Hands {
}
}
-/// Whether an event makes sense on the page.
-fn fits(input: &BrowserInput) -> bool {
- let on_page = |x: f64, y: f64| {
- (0.0..=f64::from(WIDTH)).contains(&x) && (0.0..=f64::from(HEIGHT)).contains(&y)
- };
+/// Whether an event makes sense on a page of that size.
+fn fits(input: &BrowserInput, page: Viewport) -> bool {
+ let on_page = |x: f64, y: f64| page.contains(x, y);
let modifiers_ok = |modifiers: u32| modifiers <= 15;
match input {
BrowserInput::Mouse {
@@ -276,11 +275,15 @@ mod tests {
#[test]
fn events_off_the_page_or_too_big_are_refused() {
+ let fits = |input: &BrowserInput| super::fits(input, Viewport::default());
assert!(fits(&mouse(640.0, 400.0)));
assert!(fits(&mouse(1280.0, 800.0)));
assert!(!fits(&mouse(-1.0, 10.0)));
assert!(!fits(&mouse(f64::NAN, 10.0)));
assert!(!fits(&mouse(10.0, 900.0)));
+ // A taller page takes points further down.
+ let tall = Viewport::fitting(640, 700);
+ assert!(super::fits(&mouse(10.0, 900.0), tall));
let long = BrowserInput::Text {
text: "x".repeat(TEXT_MAX + 1),
};
diff --git a/crates/botloftd/src/browser/mod.rs b/crates/botloftd/src/browser/mod.rs
index 662a3f4b..15684bce 100644
--- a/crates/botloftd/src/browser/mod.rs
+++ b/crates/botloftd/src/browser/mod.rs
@@ -16,6 +16,7 @@ mod session;
mod settle;
pub mod sites;
mod sweep;
+mod viewport;
mod watch;
use std::collections::HashMap;
@@ -39,6 +40,7 @@ pub use self::page::{Done, Scroll};
pub use self::read::{READ_MAX, Reading};
pub use self::session::Session;
pub use self::sweep::run;
+pub use self::viewport::Viewport;
pub use self::watch::Watching;
use crate::clock::Clock;
use crate::config::Config;
@@ -111,6 +113,8 @@ struct Slot {
state: BrowserState,
frames: Frames,
watchers: usize,
+ /// The page size the app watching asked for (spec 21.3).
+ viewport: Viewport,
used: Instant,
held: Held,
/// The bot's open request for the owner's help.
@@ -161,6 +165,7 @@ impl Browsers {
state: BrowserState::closed(bot.clone(), self.clock.now_ms()),
frames: tokio::sync::watch::channel(None).0,
watchers: 0,
+ viewport: Viewport::default(),
used: Instant::now(),
held: tokio::sync::watch::channel(None).0,
asking: None,
diff --git a/crates/botloftd/src/browser/page.rs b/crates/botloftd/src/browser/page.rs
index e0c628aa..2f5baaa2 100644
--- a/crates/botloftd/src/browser/page.rs
+++ b/crates/botloftd/src/browser/page.rs
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
use super::BrowserError;
use super::keys::{self, Key};
-use super::session::{HEIGHT, Session, WIDTH};
+use super::session::Session;
/// Where an action happened, for the owner's view, and what the bot should
/// know about it.
@@ -204,10 +204,15 @@ impl Session {
pub async fn scroll(&self, to: Scroll) -> Result {
let (session, _) = self.page()?;
let marks = self.marks();
- let center = (f64::from(WIDTH) / 2.0, f64::from(HEIGHT) / 2.0);
+ let screen = self.viewport();
+ let center = (
+ f64::from(screen.width) / 2.0,
+ f64::from(screen.height) / 2.0,
+ );
let done = match to {
Scroll::Down | Scroll::Up => {
- let delta = f64::from(HEIGHT) * 0.8 * if to == Scroll::Down { 1.0 } else { -1.0 };
+ let delta =
+ f64::from(screen.height) * 0.8 * if to == Scroll::Down { 1.0 } else { -1.0 };
let params = json!({
"type": "mouseWheel", "x": center.0, "y": center.1, "deltaX": 0, "deltaY": delta,
});
diff --git a/crates/botloftd/src/browser/session.rs b/crates/botloftd/src/browser/session.rs
index 345b8f79..e51e0e39 100644
--- a/crates/botloftd/src/browser/session.rs
+++ b/crates/botloftd/src/browser/session.rs
@@ -17,10 +17,8 @@ use super::BrowserError;
use super::cdp::Cdp;
use super::events::pump;
use super::launch::{self, BrowserProcess};
+use super::viewport::{MAX_HEIGHT, Viewport, WIDTH};
-/// The page size bots and the owner see, in CSS pixels (spec 21.3).
-pub const WIDTH: u32 = 1280;
-pub const HEIGHT: u32 = 800;
/// Most tabs a browser keeps; the oldest inactive one closes past this.
pub(super) const MAX_TABS: usize = 6;
/// Frames are confirmed after this, so the browser sends ~15 a second.
@@ -68,6 +66,7 @@ pub struct Tabs {
pub(super) downloads: HashMap,
pub watching: bool,
pub closed: bool,
+ pub viewport: Viewport,
}
impl Tabs {
@@ -101,14 +100,18 @@ pub struct Session {
pub next_ref: AtomicU64,
process: Mutex