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}