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    /// Short identifier reported in `vs_status`.
176    pub name: &'static str,
177    /// Engine version string. Empty for the stub.
178    pub version: &'static str,
179}
180
181impl EngineCapabilities {
182    /// Capabilities advertised by the in-memory `TestEngine` (in
183    /// `test_support`). Sealed behind the `test-support` Cargo
184    /// feature; production builds of the daemon never see this.
185    pub const TEST: Self = Self {
186        renders: true,
187        honors_viewport: true,
188        measures_layout: true,
189        persists_auth: true,
190        inspector_console: true,
191        inspector_network: true,
192        name: "test",
193        version: "",
194    };
195}
196
197/// Opaque encrypted-by-the-engine auth payload. The daemon hands the
198/// bytes to [`vs_store`](vs_protocol) for at-rest encryption.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct AuthBlob {
201    pub bytes: Vec<u8>,
202}
203
204/// What can go wrong when driving an engine.
205#[derive(Debug, Clone, thiserror::Error)]
206#[non_exhaustive]
207pub enum EngineError {
208    /// The engine cannot service this primitive on this platform.
209    #[error("engine `{engine}` does not support `{primitive}`")]
210    Unsupported {
211        engine: &'static str,
212        primitive: &'static str,
213    },
214
215    /// Operation exceeded its deadline.
216    #[error("timeout after {budget:?}: {primitive}")]
217    Timeout {
218        budget: Duration,
219        primitive: &'static str,
220    },
221
222    /// The named ref or mark is not present.
223    #[error("not found: {kind} {id}")]
224    NotFound { kind: &'static str, id: String },
225
226    /// The engine thread panicked or is otherwise non-responsive.
227    #[error("engine thread crashed")]
228    Crashed,
229
230    /// The engine has shut down and is no longer accepting commands.
231    #[error("engine has shut down")]
232    Closed,
233
234    /// Backend recognizes the primitive but has not implemented it.
235    /// Distinct from [`Self::Unsupported`]: the platform *can* support
236    /// it, the port just isn't there yet.
237    #[error("engine `{engine}` has not implemented `{primitive}`")]
238    NotImplemented {
239        engine: &'static str,
240        primitive: &'static str,
241    },
242
243    /// Anything else (engine-specific failure mode).
244    #[error("{0}")]
245    Other(String),
246}
247
248/// Convenience [`Result`] with [`EngineError`] as the error type.
249pub type EngineResult<T> = std::result::Result<T, EngineError>;
250
251/// The browser engine, as the daemon sees it.
252///
253/// All methods are synchronous from the daemon's perspective. The
254/// [`runtime`](crate::runtime) layer dispatches calls onto a dedicated
255/// thread that owns the platform run loop; implementations should
256/// assume they are running on that thread.
257pub trait Engine {
258    /// Open a fresh page navigated to `url`.
259    fn open(&mut self, url: &str) -> EngineResult<PageHandle>;
260
261    /// Close a page. Idempotent — closing a closed page is a no-op.
262    fn close(&mut self, page: PageHandle) -> EngineResult<()>;
263
264    /// Snapshot the a11y tree at `page`.
265    fn snapshot(&mut self, page: PageHandle) -> EngineResult<Tree>;
266
267    /// Perform `action` on `target` at `page`.
268    fn act(&mut self, page: PageHandle, target: ActTarget, action: Action) -> EngineResult<()>;
269
270    /// Wait for `cond` at `page` until satisfied or `budget` elapses.
271    fn wait(&mut self, page: PageHandle, cond: WaitCondition, budget: Duration)
272        -> EngineResult<()>;
273
274    /// Take a screenshot. Returns the path on disk where the image was
275    /// written.
276    fn capture(&mut self, page: PageHandle, scope: CaptureScope) -> EngineResult<PathBuf>;
277
278    /// Compute layout boxes for `refs` at `page`.
279    fn layout(&mut self, page: PageHandle, refs: &[Ref]) -> EngineResult<Vec<LayoutBox>>;
280
281    /// Set the viewport at `page`. Triggers a re-baseline for the next
282    /// `snapshot`.
283    fn set_viewport(&mut self, page: PageHandle, viewport: Viewport) -> EngineResult<()>;
284
285    /// Snapshot cookies/storage for `page` as an opaque [`AuthBlob`].
286    fn save_auth(&mut self, page: PageHandle) -> EngineResult<AuthBlob>;
287
288    /// Apply a previously saved [`AuthBlob`] to `page`.
289    fn load_auth(&mut self, page: PageHandle, blob: &AuthBlob) -> EngineResult<()>;
290
291    /// Snapshot the always-captured console ring buffer for `page`.
292    /// Default impl returns empty (engines without console capture).
293    fn console_entries(
294        &mut self,
295        _page: PageHandle,
296    ) -> EngineResult<Vec<crate::inspector::ConsoleEntry>> {
297        Ok(Vec::new())
298    }
299
300    /// Snapshot the always-captured network ring buffer for `page`.
301    /// Default impl returns empty (engines without network capture).
302    fn network_entries(
303        &mut self,
304        _page: PageHandle,
305    ) -> EngineResult<Vec<crate::inspector::NetworkEntry>> {
306        Ok(Vec::new())
307    }
308
309    /// Look up the full detail (headers + bodies) for a single
310    /// captured network request. Returns `None` if the seq isn't in
311    /// the buffer. Default impl returns `None`.
312    fn request_detail(
313        &mut self,
314        _page: PageHandle,
315        _seq: u64,
316    ) -> EngineResult<Option<crate::inspector::RequestDetail>> {
317        Ok(None)
318    }
319
320    /// Evaluate `expr` in main world. Default impl returns
321    /// `EvalResult::Thrown { kind: "NotImplemented", ... }`.
322    fn eval_js(
323        &mut self,
324        _page: PageHandle,
325        _expr: &str,
326    ) -> EngineResult<crate::inspector::EvalResult> {
327        Ok(crate::inspector::EvalResult::Thrown {
328            kind: "NotImplemented".into(),
329            message: "engine does not implement vs_inspect eval".into(),
330        })
331    }
332
333    /// List storage entries for the requested scope.
334    fn storage(
335        &mut self,
336        _page: PageHandle,
337        _scope: crate::inspector::StorageScope,
338    ) -> EngineResult<Vec<crate::inspector::StorageEntry>> {
339        Ok(Vec::new())
340    }
341
342    /// List scripts loaded by the page.
343    fn scripts(&mut self, _page: PageHandle) -> EngineResult<Vec<crate::inspector::ScriptEntry>> {
344        Ok(Vec::new())
345    }
346
347    /// Source of one script by `seq`. Returns `None` if the seq isn't
348    /// in the script registry.
349    fn script_source(
350        &mut self,
351        _page: PageHandle,
352        _seq: u64,
353    ) -> EngineResult<Option<crate::inspector::ScriptSource>> {
354        Ok(None)
355    }
356
357    /// Outer HTML + computed styles for a ref. Caller-requested
358    /// computed properties land in `extra_props`; the engine merges
359    /// them with its default property set. Returns `None` for unknown
360    /// refs.
361    fn dom(
362        &mut self,
363        _page: PageHandle,
364        _r: vs_protocol::Ref,
365        _extra_props: &[String],
366    ) -> EngineResult<Option<crate::inspector::DomDetail>> {
367        Ok(None)
368    }
369
370    /// Web Vitals + heap + DOM stats. Returns zero-filled defaults if
371    /// the engine can't measure them.
372    fn performance(
373        &mut self,
374        _page: PageHandle,
375    ) -> EngineResult<crate::inspector::PerformanceMetrics> {
376        Ok(crate::inspector::PerformanceMetrics::default())
377    }
378    /// Capabilities the daemon should advertise for this engine.
379    fn capabilities(&self) -> EngineCapabilities;
380}
381
382/// The role a node in `Node` would carry — re-exported for convenience.
383pub use vs_protocol::Role as NodeRole;
384
385#[cfg(test)]
386mod tests {
387    use super::DEFAULT_USER_AGENT;
388
389    /// Pins the UA contract: must look like a current shipping
390    /// Safari, including the `Version/X` and `Safari/Y` suffixes.
391    /// The whole reason this constant exists is that the WKWebView
392    /// default UA drops both — sites that fingerprint UAs (Google,
393    /// Cloudflare, etc.) flag every request without them.
394    ///
395    /// If this test fails because you bumped the version, that's
396    /// fine — update the literal. If it fails because the format
397    /// changed, think hard before relaxing it.
398    #[test]
399    fn default_user_agent_has_safari_suffix() {
400        let ua = DEFAULT_USER_AGENT;
401        assert!(
402            ua.starts_with("Mozilla/5.0 "),
403            "UA should start with `Mozilla/5.0 `; got {ua:?}",
404        );
405        assert!(
406            ua.contains("AppleWebKit/"),
407            "UA should advertise WebKit; got {ua:?}",
408        );
409        assert!(
410            ua.contains("Version/"),
411            "UA missing `Version/X` — anti-bot will flag it; got {ua:?}",
412        );
413        assert!(
414            ua.contains("Safari/"),
415            "UA missing `Safari/X` — anti-bot will flag it; got {ua:?}",
416        );
417        // Single-line — extra whitespace inside is fine, but no
418        // newlines / tabs.
419        assert!(
420            !ua.contains('\n') && !ua.contains('\r') && !ua.contains('\t'),
421            "UA must be a single line; got {ua:?}",
422        );
423    }
424}