vs_engine_webkit/engine.rs
1//! The [`Engine`] trait — the daemon's view of "a browser".
2//!
3//! The trait is small, synchronous, and platform-agnostic. The daemon
4//! drives an `Engine` from a Tokio context but never directly: every
5//! call is dispatched onto the engine's dedicated thread (see
6//! [`crate::runtime`]), where the platform-specific WebKit port runs.
7//!
8//! For the live wire types — [`Tree`], [`Node`], [`Ref`], [`Role`],
9//! [`Op`] — see [`vs_protocol`].
10
11use std::path::PathBuf;
12use std::time::Duration;
13
14use vs_protocol::{Op, Ref, Tree};
15
16/// Default `User-Agent` the engine sets on each page. Mirrors a recent
17/// shipping Safari on macOS so anti-bot fingerprinters that match on
18/// the WKWebView default (which lacks the `Version/X Safari/X`
19/// suffix) don't flag every request. Backends that support
20/// per-webview UA (WKWebView via `setCustomUserAgent`, WebKitGTK via
21/// `webkit_settings_set_user_agent`, WebView2 via `Profile2` /
22/// `coreWebView2.Settings.UserAgent`) apply this string at construction.
23pub const DEFAULT_USER_AGENT: &str =
24 "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 \
25 (KHTML, like Gecko) Version/17.5 Safari/605.1.15";
26
27/// An opaque engine-side handle to a page. The daemon associates each
28/// `PageHandle` with one `pages` row in [`vs-store`](vs_protocol).
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
30pub struct PageHandle(pub u64);
31
32/// What [`Engine::act`] targets on a page.
33#[derive(Debug, Clone)]
34pub enum ActTarget {
35 /// A live ref from the most recent snapshot.
36 Ref(Ref),
37 /// A persistent mark, identified by name.
38 Mark(String),
39}
40
41/// Coordinate-addressed input operations: `vs move-to`, `vs click-at`,
42/// `vs hover-at`, `vs drag`. Native event dispatch on backends that
43/// support it (macOS today; Linux + Windows fall through to
44/// `ENGINE_UNSUPPORTED` until M7 wires GDK / CDP input).
45#[derive(Debug, Clone, Copy)]
46pub enum CursorOp {
47 /// Move the pointer to `(x, y)` without clicking.
48 MoveTo { x: f64, y: f64 },
49 /// Click at `(x, y)`. Includes a humanized lead-in from the last
50 /// known cursor position when the mode is `Human`.
51 ClickAt { x: f64, y: f64 },
52 /// Hover (mouseover) at `(x, y)`.
53 HoverAt { x: f64, y: f64 },
54 /// Press at `(x1, y1)`, drag along a humanized path to `(x2, y2)`,
55 /// release.
56 Drag { x1: f64, y1: f64, x2: f64, y2: f64 },
57}
58
59/// Input humanization mode. Wire form: `--mode={human,careful,robotic}`.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum InputMode {
62 Human,
63 Careful,
64 Robotic,
65}
66
67impl InputMode {
68 #[must_use]
69 pub fn parse(s: &str) -> Option<Self> {
70 match s.to_ascii_lowercase().as_str() {
71 "human" | "h" => Some(Self::Human),
72 "careful" | "c" => Some(Self::Careful),
73 "robotic" | "r" => Some(Self::Robotic),
74 _ => None,
75 }
76 }
77 #[must_use]
78 pub fn as_str(self) -> &'static str {
79 match self {
80 Self::Human => "human",
81 Self::Careful => "careful",
82 Self::Robotic => "robotic",
83 }
84 }
85}
86
87/// What [`Engine::act`] does at the target.
88#[derive(Debug, Clone)]
89pub enum Action {
90 /// Activate (mouse click or `Enter`).
91 Click,
92 /// Replace the text content of an editable target.
93 Fill { value: String },
94 /// Scroll the target into view.
95 Scroll,
96 /// Send a key chord (`Enter`, `Ctrl+K`, …).
97 Key { chord: String },
98 /// Submit the enclosing form.
99 Submit,
100 /// Move the pointer over the target.
101 Hover,
102 /// Move keyboard focus.
103 Focus,
104}
105
106impl Action {
107 /// The protocol [`Op`](vs_protocol::Op) corresponding to this action.
108 #[must_use]
109 pub fn op(&self) -> Op {
110 match self {
111 Self::Click => Op::Click,
112 Self::Fill { .. } => Op::Fill,
113 Self::Scroll => Op::Scroll,
114 Self::Key { .. } => Op::Key,
115 Self::Submit => Op::Submit,
116 Self::Hover => Op::Hover,
117 Self::Focus => Op::Focus,
118 }
119 }
120}
121
122/// A condition for [`Engine::wait`].
123#[derive(Debug, Clone)]
124pub enum WaitCondition {
125 /// A11y tree stable for 250ms.
126 Stable,
127 /// Network idle (`<= 0` in-flight requests for 500ms).
128 NetIdle,
129 /// `Ref(r)` appears in the tree.
130 RefAppears(Ref),
131 /// `Ref(r)` disappears from the tree.
132 RefGone(Ref),
133 /// Substring matches anywhere in the tree.
134 Text(String),
135 /// `state_token` changes.
136 TokenChange,
137}
138
139/// Scope of a [`Engine::capture`] call.
140#[derive(Debug, Clone)]
141pub enum CaptureScope {
142 Viewport,
143 Element(Ref),
144 FullPage,
145}
146
147/// A viewport definition. Sizes are in CSS pixels; `dpr` is the
148/// device-pixel ratio.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub struct Viewport {
151 pub width: u32,
152 pub height: u32,
153 pub dpr: u32,
154}
155
156impl Viewport {
157 pub const MOBILE_S: Self = Self::new(320, 568, 2);
158 pub const MOBILE: Self = Self::new(390, 844, 2);
159 pub const MOBILE_L: Self = Self::new(414, 896, 2);
160 pub const TABLET: Self = Self::new(768, 1024, 2);
161 pub const TABLET_L: Self = Self::new(1024, 768, 2);
162 pub const LAPTOP: Self = Self::new(1366, 768, 2);
163 pub const DESKTOP: Self = Self::new(1920, 1080, 2);
164 pub const DESKTOP_XL: Self = Self::new(2560, 1440, 2);
165 pub const WIDE: Self = Self::new(3440, 1440, 2);
166
167 #[must_use]
168 pub const fn new(width: u32, height: u32, dpr: u32) -> Self {
169 Self { width, height, dpr }
170 }
171
172 /// Look up a preset by its protocol name (e.g. `"mobile"`,
173 /// `"desktop-xl"`). Returns `None` for unknown presets.
174 #[must_use]
175 pub fn preset(name: &str) -> Option<Self> {
176 match name {
177 "mobile-s" => Some(Self::MOBILE_S),
178 "mobile" => Some(Self::MOBILE),
179 "mobile-l" => Some(Self::MOBILE_L),
180 "tablet" => Some(Self::TABLET),
181 "tablet-l" => Some(Self::TABLET_L),
182 "laptop" => Some(Self::LAPTOP),
183 "desktop" => Some(Self::DESKTOP),
184 "desktop-xl" => Some(Self::DESKTOP_XL),
185 "wide" => Some(Self::WIDE),
186 _ => None,
187 }
188 }
189}
190
191/// Result of [`Engine::layout`]: the computed box for a single ref.
192#[derive(Debug, Clone, Copy, PartialEq)]
193pub struct LayoutBox {
194 pub r: Ref,
195 pub x: f64,
196 pub y: f64,
197 pub width: f64,
198 pub height: f64,
199 pub visible: bool,
200 pub z_index: i32,
201}
202
203/// What an engine declares it can and cannot do.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205#[allow(clippy::struct_excessive_bools)]
206pub struct EngineCapabilities {
207 /// Renders pixels (pages are visually realized; `capture` works).
208 pub renders: bool,
209 /// Honors `set_viewport` requests.
210 pub honors_viewport: bool,
211 /// Computes layout boxes (`layout` works).
212 pub measures_layout: bool,
213 /// Persists and restores cookies / storage (`save_auth` / `load_auth` work).
214 pub persists_auth: bool,
215 /// Captures `console.*` and uncaught exceptions in a ring buffer
216 /// (M5.7 PR1+). Surfaced via `vs_inspect console`.
217 pub inspector_console: bool,
218 /// Captures network requests in a ring buffer (M5.7 PR1+).
219 /// Surfaced via `vs_inspect network`.
220 pub inspector_network: bool,
221 /// Captures cookie-store ADD/REMOVE events in a ring buffer
222 /// (v0.1.6+). Surfaced via `vs_inspect cookie-events`. macOS and
223 /// Linux only — Windows reports `false` because `webview2-com` has
224 /// no cookie-changed observer.
225 pub inspector_cookie_events: bool,
226 /// Short identifier reported in `vs_status`.
227 pub name: &'static str,
228 /// Engine version string. Empty for the stub.
229 pub version: &'static str,
230}
231
232impl EngineCapabilities {
233 /// Capabilities advertised by the in-memory `TestEngine` (in
234 /// `test_support`). Sealed behind the `test-support` Cargo
235 /// feature; production builds of the daemon never see this.
236 pub const TEST: Self = Self {
237 renders: true,
238 honors_viewport: true,
239 measures_layout: true,
240 persists_auth: true,
241 inspector_console: true,
242 inspector_network: true,
243 inspector_cookie_events: true,
244 name: "test",
245 version: "",
246 };
247}
248
249/// Opaque encrypted-by-the-engine auth payload. The daemon hands the
250/// bytes to [`vs_store`](vs_protocol) for at-rest encryption.
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct AuthBlob {
253 pub bytes: Vec<u8>,
254}
255
256/// What can go wrong when driving an engine.
257#[derive(Debug, Clone, thiserror::Error)]
258#[non_exhaustive]
259pub enum EngineError {
260 /// The engine cannot service this primitive on this platform.
261 #[error("engine `{engine}` does not support `{primitive}`")]
262 Unsupported {
263 engine: &'static str,
264 primitive: &'static str,
265 },
266
267 /// Operation exceeded its deadline.
268 #[error("timeout after {budget:?}: {primitive}")]
269 Timeout {
270 budget: Duration,
271 primitive: &'static str,
272 },
273
274 /// The named ref or mark is not present.
275 #[error("not found: {kind} {id}")]
276 NotFound { kind: &'static str, id: String },
277
278 /// The engine thread panicked or is otherwise non-responsive.
279 #[error("engine thread crashed")]
280 Crashed,
281
282 /// The engine has shut down and is no longer accepting commands.
283 #[error("engine has shut down")]
284 Closed,
285
286 /// Backend recognizes the primitive but has not implemented it.
287 /// Distinct from [`Self::Unsupported`]: the platform *can* support
288 /// it, the port just isn't there yet.
289 #[error("engine `{engine}` has not implemented `{primitive}`")]
290 NotImplemented {
291 engine: &'static str,
292 primitive: &'static str,
293 },
294
295 /// Anything else (engine-specific failure mode).
296 #[error("{0}")]
297 Other(String),
298}
299
300/// Convenience [`Result`] with [`EngineError`] as the error type.
301pub type EngineResult<T> = std::result::Result<T, EngineError>;
302
303/// The browser engine, as the daemon sees it.
304///
305/// All methods are synchronous from the daemon's perspective. The
306/// [`runtime`](crate::runtime) layer dispatches calls onto a dedicated
307/// thread that owns the platform run loop; implementations should
308/// assume they are running on that thread.
309pub trait Engine {
310 /// Open a fresh page navigated to `url`.
311 fn open(&mut self, url: &str) -> EngineResult<PageHandle>;
312
313 /// Close a page. Idempotent — closing a closed page is a no-op.
314 fn close(&mut self, page: PageHandle) -> EngineResult<()>;
315
316 /// Snapshot the a11y tree at `page`.
317 fn snapshot(&mut self, page: PageHandle) -> EngineResult<Tree>;
318
319 /// Perform `action` on `target` at `page`.
320 fn act(&mut self, page: PageHandle, target: ActTarget, action: Action) -> EngineResult<()>;
321
322 /// Wait for `cond` at `page` until satisfied or `budget` elapses.
323 fn wait(&mut self, page: PageHandle, cond: WaitCondition, budget: Duration)
324 -> EngineResult<()>;
325
326 /// Take a screenshot. Returns the path on disk where the image was
327 /// written.
328 fn capture(&mut self, page: PageHandle, scope: CaptureScope) -> EngineResult<PathBuf>;
329
330 /// Compute layout boxes for `refs` at `page`.
331 fn layout(&mut self, page: PageHandle, refs: &[Ref]) -> EngineResult<Vec<LayoutBox>>;
332
333 /// Set the viewport at `page`. Triggers a re-baseline for the next
334 /// `snapshot`.
335 fn set_viewport(&mut self, page: PageHandle, viewport: Viewport) -> EngineResult<()>;
336
337 /// Snapshot cookies/storage for `page` as an opaque [`AuthBlob`].
338 fn save_auth(&mut self, page: PageHandle) -> EngineResult<AuthBlob>;
339
340 /// Apply a previously saved [`AuthBlob`] to `page`.
341 fn load_auth(&mut self, page: PageHandle, blob: &AuthBlob) -> EngineResult<()>;
342
343 /// Snapshot the always-captured console ring buffer for `page`.
344 /// Default impl returns empty (engines without console capture).
345 fn console_entries(
346 &mut self,
347 _page: PageHandle,
348 ) -> EngineResult<Vec<crate::inspector::ConsoleEntry>> {
349 Ok(Vec::new())
350 }
351
352 /// Snapshot the always-captured network ring buffer for `page`.
353 /// Default impl returns empty (engines without network capture).
354 fn network_entries(
355 &mut self,
356 _page: PageHandle,
357 ) -> EngineResult<Vec<crate::inspector::NetworkEntry>> {
358 Ok(Vec::new())
359 }
360
361 /// Look up the full detail (headers + bodies) for a single
362 /// captured network request. Returns `None` if the seq isn't in
363 /// the buffer. Default impl returns `None`.
364 fn request_detail(
365 &mut self,
366 _page: PageHandle,
367 _seq: u64,
368 ) -> EngineResult<Option<crate::inspector::RequestDetail>> {
369 Ok(None)
370 }
371
372 /// Evaluate `expr` in main world. Default impl returns
373 /// `EvalResult::Thrown { kind: "NotImplemented", ... }`.
374 fn eval_js(
375 &mut self,
376 _page: PageHandle,
377 _expr: &str,
378 ) -> EngineResult<crate::inspector::EvalResult> {
379 Ok(crate::inspector::EvalResult::Thrown {
380 kind: "NotImplemented".into(),
381 message: "engine does not implement vs_inspect eval".into(),
382 })
383 }
384
385 /// List storage entries for the requested scope.
386 fn storage(
387 &mut self,
388 _page: PageHandle,
389 _scope: crate::inspector::StorageScope,
390 ) -> EngineResult<Vec<crate::inspector::StorageEntry>> {
391 Ok(Vec::new())
392 }
393
394 /// List cookie store ADD/REMOVE events observed since the page
395 /// opened. Engines without a cookie-changed observer (Windows, the
396 /// stub) return an empty vec or `ENGINE_UNSUPPORTED` via their
397 /// capability flag.
398 fn cookie_events(
399 &mut self,
400 _page: PageHandle,
401 ) -> EngineResult<Vec<crate::inspector::CookieEvent>> {
402 Ok(Vec::new())
403 }
404
405 /// Coordinate-addressed cursor operation. Backends that don't
406 /// implement native input dispatch fall through to the default
407 /// `ENGINE_UNSUPPORTED` here; agents see `! ENGINE_UNSUPPORTED`
408 /// on the wire and can switch to ref-based `vs act` instead.
409 fn cursor_op(
410 &mut self,
411 _page: PageHandle,
412 _op: CursorOp,
413 _mode: InputMode,
414 ) -> EngineResult<()> {
415 Err(EngineError::Unsupported {
416 engine: self.capabilities().name,
417 primitive: "cursor_op",
418 })
419 }
420 /// List scripts loaded by the page.
421 fn scripts(&mut self, _page: PageHandle) -> EngineResult<Vec<crate::inspector::ScriptEntry>> {
422 Ok(Vec::new())
423 }
424
425 /// Source of one script by `seq`. Returns `None` if the seq isn't
426 /// in the script registry.
427 fn script_source(
428 &mut self,
429 _page: PageHandle,
430 _seq: u64,
431 ) -> EngineResult<Option<crate::inspector::ScriptSource>> {
432 Ok(None)
433 }
434
435 /// Outer HTML + computed styles for a ref. Caller-requested
436 /// computed properties land in `extra_props`; the engine merges
437 /// them with its default property set. Returns `None` for unknown
438 /// refs.
439 fn dom(
440 &mut self,
441 _page: PageHandle,
442 _r: vs_protocol::Ref,
443 _extra_props: &[String],
444 ) -> EngineResult<Option<crate::inspector::DomDetail>> {
445 Ok(None)
446 }
447
448 /// Web Vitals + heap + DOM stats. Returns zero-filled defaults if
449 /// the engine can't measure them.
450 fn performance(
451 &mut self,
452 _page: PageHandle,
453 ) -> EngineResult<crate::inspector::PerformanceMetrics> {
454 Ok(crate::inspector::PerformanceMetrics::default())
455 }
456 /// Capabilities the daemon should advertise for this engine.
457 fn capabilities(&self) -> EngineCapabilities;
458}
459
460/// The role a node in `Node` would carry — re-exported for convenience.
461pub use vs_protocol::Role as NodeRole;
462
463#[cfg(test)]
464mod tests {
465 use super::DEFAULT_USER_AGENT;
466
467 /// Pins the UA contract: must look like a current shipping
468 /// Safari, including the `Version/X` and `Safari/Y` suffixes.
469 /// The whole reason this constant exists is that the WKWebView
470 /// default UA drops both — sites that fingerprint UAs (Google,
471 /// Cloudflare, etc.) flag every request without them.
472 ///
473 /// If this test fails because you bumped the version, that's
474 /// fine — update the literal. If it fails because the format
475 /// changed, think hard before relaxing it.
476 #[test]
477 fn default_user_agent_has_safari_suffix() {
478 let ua = DEFAULT_USER_AGENT;
479 assert!(
480 ua.starts_with("Mozilla/5.0 "),
481 "UA should start with `Mozilla/5.0 `; got {ua:?}",
482 );
483 assert!(
484 ua.contains("AppleWebKit/"),
485 "UA should advertise WebKit; got {ua:?}",
486 );
487 assert!(
488 ua.contains("Version/"),
489 "UA missing `Version/X` — anti-bot will flag it; got {ua:?}",
490 );
491 assert!(
492 ua.contains("Safari/"),
493 "UA missing `Safari/X` — anti-bot will flag it; got {ua:?}",
494 );
495 // Single-line — extra whitespace inside is fine, but no
496 // newlines / tabs.
497 assert!(
498 !ua.contains('\n') && !ua.contains('\r') && !ua.contains('\t'),
499 "UA must be a single line; got {ua:?}",
500 );
501 }
502}