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}