Skip to main content

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}