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