Skip to main content

smix_driver/
android.rs

1//! Android `Driver` impl.
2//!
3//! Wraps [`HttpRunnerClient`] talking to the Android-side Kotlin runner
4//! (an APK running a KTOR HTTP server backed by UiAutomator2). The
5//! runner is reached via `adb forward tcp:HOST tcp:DEVICE` so host-side
6//! HTTP transport is identical to iOS.
7//!
8//! Each of the 26 sense+act methods either delegates transparently to
9//! the runner where the wire shape is reusable, or returns an explicit
10//! "endpoint not yet shipped" error — failures are visible, never
11//! silent.
12//!
13//! End-to-end acceptance needs a Kotlin runner APK installed on a
14//! booted emulator; the host side is unit-tested via `Box<dyn Driver>`
15//! dyn-compatibility and platform=Android probes.
16
17use async_trait::async_trait;
18use std::time::Duration;
19
20use smix_error::{ExpectationFailure, FailureCode, FailureInit};
21use smix_host_coord_resolver::{HostResolveError, resolve_to_norm_coord};
22use smix_input::{KeyName, SwipeDirection};
23use smix_runner_client::{HttpRunnerClient, IncludeScope, OcrFrame, SystemPopup, TapMode};
24use smix_screen::{A11yNode, DEFAULT_VISIBLE_LIMIT, collect_visible_summaries};
25use smix_selector::{Selector, describe_selector};
26use smix_selector_resolver::{resolve_selector, resolve_selector_all};
27
28use crate::Orientation;
29use crate::traits::{Driver, Platform};
30
31/// Android `Driver` impl. Wraps `HttpRunnerClient` connecting to the
32/// Kotlin runner via adb-forwarded port (default 28080, configurable
33/// via `AndroidDriver::new(port)`).
34pub struct AndroidDriver {
35    runner: HttpRunnerClient,
36    /// Skip host-side focus resolution and type into whatever holds
37    /// focus. Android has no runner-side dispatch switch to send —
38    /// `/input-text` already types into the focused field — so the
39    /// mode is honoured here, by not resolving.
40    force_key_events: bool,
41}
42
43impl AndroidDriver {
44    #[must_use]
45    pub fn new(runner: HttpRunnerClient) -> Self {
46        AndroidDriver {
47            runner,
48            force_key_events: false,
49        }
50    }
51
52    pub fn runner(&self) -> &HttpRunnerClient {
53        &self.runner
54    }
55}
56
57/// Host-resolve loop with 5s implicit-wait + 250ms poll.
58/// Returns viewport-normalized centroid coord. Shared by tap / double_tap
59/// / long_press / fill / clear.
60async fn resolve_with_implicit_wait(
61    driver: &AndroidDriver,
62    selector: &Selector,
63    include: Option<IncludeScope>,
64) -> Result<(f64, f64), ExpectationFailure> {
65    let start = std::time::Instant::now();
66    let timeout = Duration::from_millis(5000);
67    loop {
68        let tree = driver.tree(include).await?;
69        match resolve_to_norm_coord(&tree, selector) {
70            Ok(coord) => return Ok(coord),
71            Err(HostResolveError::NotFound) => {
72                if start.elapsed() > timeout {
73                    return Err(ExpectationFailure::new(FailureInit {
74                        code: Some(FailureCode::ElementNotFound),
75                        message: format!(
76                            "AndroidDriver: element not found: {}",
77                            describe_selector(selector)
78                        ),
79                        selector: Some(selector.clone()),
80                        ..Default::default()
81                    }));
82                }
83                tokio::time::sleep(Duration::from_millis(250)).await;
84                continue;
85            }
86            Err(e) => {
87                return Err(ExpectationFailure::new(FailureInit {
88                    code: Some(FailureCode::DriverError),
89                    message: format!("AndroidDriver: resolve error: {e:?}"),
90                    selector: Some(selector.clone()),
91                    ..Default::default()
92                }));
93            }
94        }
95    }
96}
97
98/// `dispatch:` overrides are an iOS-runner mechanism.
99///
100/// The guide says this "errors with an explicit unsupported message";
101/// what it actually said was "not implemented by the Kotlin runner",
102/// which reads as a missing feature someone should wait for. There is
103/// nothing to wait for: Android's default tap already IS native event
104/// synthesis, which is what the override buys on iOS. The fix is to
105/// drop the key, so the error says that.
106fn dispatch_unsupported_err() -> ExpectationFailure {
107    ExpectationFailure::new(FailureInit {
108        code: Some(FailureCode::DriverError),
109        message: "tapOn `dispatch:` is an iOS-runner mechanism and has no \
110                  meaning on Android"
111            .to_string(),
112        hint: Some(
113            "remove `dispatch:` from this step — Android taps already use \
114             native event synthesis, which is what the override selects on iOS"
115                .to_string(),
116        ),
117        ..Default::default()
118    })
119}
120
121#[async_trait]
122impl Driver for AndroidDriver {
123    fn platform(&self) -> Platform {
124        Platform::Android
125    }
126
127    // No as_ios_driver override — uses default `None` from trait.
128
129    /// Android impl: send the package of the app under test as
130    /// `App-Bundle-Id` on every request.
131    ///
132    /// It does not pin the runner to one app the way the iOS header
133    /// does — Android's `/tree` walks every attached window and there
134    /// is no `XCUIApplication` to rebind. What needs it is id lookup:
135    /// Compose emits `<pkg>:id/<tag>` on some layouts, and the runner
136    /// cannot construct that spelling without knowing the package.
137    fn set_target_bundle_id(&mut self, bundle: &str) {
138        self.runner.set_target_bundle_id(bundle);
139    }
140
141    /// Android impl: attach / clear the `Session-Id` header on every
142    /// subsequent request.
143    ///
144    /// The Kotlin runner does serve the `/session/*` routes and keeps a
145    /// `SessionTable`; sessions are optional there rather than absent,
146    /// which is what "Android drives sessionless" is shorthand for. A
147    /// flow that never opens one still works, because every action
148    /// route resolves without consulting the table.
149    fn set_session_id(&mut self, id: Option<String>) {
150        match id {
151            Some(sid) => self.runner.set_session_id(sid),
152            None => self.runner.clear_session_id(),
153        }
154    }
155
156    // === Sense ===
157
158    async fn tree(&self, include: Option<IncludeScope>) -> Result<A11yNode, ExpectationFailure> {
159        // Delegates to Kotlin runner GET /tree (UiAutomator2
160        // dumpWindowHierarchy → A11yNode JSON shape). HttpRunnerClient
161        // is platform-agnostic; same wire as iOS.
162        self.runner.get_tree(include).await.map_err(|e| {
163            ExpectationFailure::new(FailureInit {
164                code: Some(FailureCode::DriverError),
165                message: format!("AndroidDriver::tree: {e}"),
166                ..Default::default()
167            })
168        })
169    }
170
171    async fn find(
172        &self,
173        selector: &Selector,
174        include: Option<IncludeScope>,
175    ) -> Result<bool, ExpectationFailure> {
176        // Host-resolve over tree (the Kotlin runner /find route is not
177        // needed; the tree dump already contains the whole tree).
178        let tree = self.tree(include).await?;
179        Ok(resolve_selector(&tree, selector).is_some())
180    }
181
182    async fn find_one(
183        &self,
184        selector: &Selector,
185        include: Option<IncludeScope>,
186    ) -> Result<Option<A11yNode>, ExpectationFailure> {
187        let tree = self.tree(include).await?;
188        Ok(resolve_selector(&tree, selector).cloned())
189    }
190
191    async fn find_all(
192        &self,
193        selector: &Selector,
194        include: Option<IncludeScope>,
195    ) -> Result<Vec<A11yNode>, ExpectationFailure> {
196        let tree = self.tree(include).await?;
197        Ok(resolve_selector_all(&tree, selector)
198            .into_iter()
199            .cloned()
200            .collect())
201    }
202
203    async fn find_norm_coord(
204        &self,
205        selector: &Selector,
206    ) -> Result<Option<(f64, f64)>, ExpectationFailure> {
207        let tree = self.tree(None).await?;
208        match resolve_to_norm_coord(&tree, selector) {
209            Ok(coord) => Ok(Some(coord)),
210            Err(HostResolveError::NotFound | HostResolveError::EmptyMatchedFrame) => Ok(None),
211            Err(HostResolveError::UnknownAppFrame) => Err(ExpectationFailure::new(FailureInit {
212                code: Some(FailureCode::DriverError),
213                message: "AndroidDriver::find_norm_coord: tree bounds w/h ≤ 0 (unknown app frame)"
214                    .into(),
215                ..Default::default()
216            })),
217            Err(HostResolveError::CentroidOutOfFrame { .. }) => Ok(None),
218        }
219    }
220
221    async fn find_text_by_ocr(
222        &self,
223        text: &str,
224        locales: &[String],
225        recognition_level: &str,
226    ) -> Result<Option<OcrFrame>, ExpectationFailure> {
227        // Google ML Kit Text Recognition (Latin script package).
228        // Locales + recognition_level args are iOS Apple Vision specific;
229        // Kotlin /find-text-by-ocr endpoint reads + ignores them today
230        // (ML Kit Latin handles ASCII/European text universally).
231        self.runner
232            .find_text_by_ocr(text, locales, recognition_level)
233            .await
234            .map_err(|e| {
235                ExpectationFailure::new(FailureInit {
236                    code: Some(FailureCode::DriverError),
237                    message: format!("AndroidDriver::find_text_by_ocr: {e}"),
238                    ..Default::default()
239                })
240            })
241    }
242
243    async fn system_popups(
244        &self,
245        include: Option<IncludeScope>,
246    ) -> Result<Vec<SystemPopup>, ExpectationFailure> {
247        // Kotlin /system-popups walks UiAutomation.windows and
248        // classifies dialog-shaped TYPE_APPLICATION windows. Returns
249        // envelope {popups: [...]} per HttpRunnerClient deserialization.
250        self.runner.system_popups(include).await.map_err(|e| {
251            ExpectationFailure::new(FailureInit {
252                code: Some(FailureCode::DriverError),
253                message: format!("AndroidDriver::system_popups: {e}"),
254                ..Default::default()
255            })
256        })
257    }
258
259    async fn system_popup_action(
260        &self,
261        popup_id: &str,
262        button_id: &str,
263    ) -> Result<bool, ExpectationFailure> {
264        // Kotlin /system-popup-action re-walks windows + finds
265        // popup by id + button by testTag-derived id + UiDevice.click on
266        // its bounding box center.
267        self.runner
268            .system_popup_action(popup_id, button_id)
269            .await
270            .map_err(|e| {
271                ExpectationFailure::new(FailureInit {
272                    code: Some(FailureCode::DriverError),
273                    message: format!("AndroidDriver::system_popup_action: {e}"),
274                    ..Default::default()
275                })
276            })
277    }
278
279    async fn wait_for(
280        &self,
281        selector: &Selector,
282        timeout: Duration,
283        include: Option<IncludeScope>,
284    ) -> Result<A11yNode, ExpectationFailure> {
285        // Poll tree at 250ms cadence up to `timeout`, return
286        // matched node on first hit. Mirror of iOS wait_for semantics.
287        let start = std::time::Instant::now();
288        loop {
289            let tree = self.tree(include).await?;
290            if let Some(node) = resolve_selector(&tree, selector) {
291                return Ok(node.clone());
292            }
293            if start.elapsed() >= timeout {
294                // Suggestions scan the whole visible tree, not just the ten
295                // displayed elements: an Android window dump leads with the
296                // navigation / status bar chrome, so the first ten
297                // identity-bearing nodes are all system UI and the app's own
298                // content (which the near-miss target actually resembles)
299                // sits far deeper. Truncating the candidate set to the
300                // display limit would blind "Did you mean ...?" to every real
301                // app element.
302                let candidates = collect_visible_summaries(&tree, DEFAULT_VISIBLE_LIMIT);
303                let target = crate::base_text_or_id(selector);
304                let suggestions = smix_error::build_suggestions(target.as_deref(), &candidates);
305                let visible = collect_visible_summaries(&tree, 10);
306                return Err(ExpectationFailure::new(FailureInit {
307                    code: Some(FailureCode::ElementNotFound),
308                    message: format!(
309                        "AndroidDriver::wait_for timeout after {}ms: {}",
310                        timeout.as_millis(),
311                        describe_selector(selector)
312                    ),
313                    selector: Some(selector.clone()),
314                    visible_elements: visible,
315                    suggestions,
316                    ..Default::default()
317                }));
318            }
319            tokio::time::sleep(Duration::from_millis(250)).await;
320        }
321    }
322
323    // === Act ===
324
325    async fn tap(
326        &self,
327        selector: &Selector,
328        include: Option<IncludeScope>,
329    ) -> Result<crate::ActOutcome, ExpectationFailure> {
330        // Host-resolve + tap_at_norm_coord (mirrors IosDriver Path B).
331        let (nx, ny) = resolve_with_implicit_wait(self, selector, include).await?;
332        // Android reports no chain yet — only the iOS runner fills it
333        // in — so the outcome says it could not be judged rather than
334        // claiming the tap landed. Wiring the Kotlin side is the other
335        // half of this checkpoint, not a line to sneak in here.
336        self.runner
337            .tap_at_norm_coord(nx, ny)
338            .await
339            .map(|_| crate::ActOutcome::unjudged())
340            .map_err(|e| {
341                ExpectationFailure::new(FailureInit {
342                    code: Some(FailureCode::DriverError),
343                    message: format!("AndroidDriver::tap: runner.tap_at_norm_coord: {e}"),
344                    ..Default::default()
345                })
346            })
347    }
348
349    async fn tap_with_mode(
350        &self,
351        _selector: &Selector,
352        _mode: TapMode,
353        _include: Option<IncludeScope>,
354    ) -> Result<(), ExpectationFailure> {
355        Err(dispatch_unsupported_err())
356    }
357
358    async fn tap_at_norm_coord(&self, nx: f64, ny: f64) -> Result<(), ExpectationFailure> {
359        // Direct passthru to Kotlin runner /tap-at-norm-coord.
360        self.runner
361            .tap_at_norm_coord(nx, ny)
362            .await
363            .map(|_| ())
364            .map_err(|e| {
365                ExpectationFailure::new(FailureInit {
366                    code: Some(FailureCode::DriverError),
367                    message: format!("AndroidDriver::tap_at_norm_coord: {e}"),
368                    ..Default::default()
369                })
370            })
371    }
372
373    async fn tap_by_id(&self, id: &str) -> Result<(), ExpectationFailure> {
374        // POST /tap-by-id with {id}. Kotlin side finds
375        // UiObject2 via By.res(short or fully-qualified) and clicks.
376        let ok = self.runner.tap_by_id(id).await.map_err(|e| {
377            ExpectationFailure::new(FailureInit {
378                code: Some(FailureCode::DriverError),
379                message: format!("AndroidDriver::tap_by_id: {e}"),
380                ..Default::default()
381            })
382        })?;
383        if !ok {
384            return Err(ExpectationFailure::new(FailureInit {
385                code: Some(FailureCode::ElementNotFound),
386                message: format!("AndroidDriver::tap_by_id: no element with resource-id '{id}'"),
387                ..Default::default()
388            }));
389        }
390        Ok(())
391    }
392
393    async fn double_tap(
394        &self,
395        selector: &Selector,
396        include: Option<IncludeScope>,
397    ) -> Result<(), ExpectationFailure> {
398        // Host-resolve + /double-tap-at-norm-coord (Kotlin
399        // side dispatches 2 clicks 150ms apart).
400        let (nx, ny) = resolve_with_implicit_wait(self, selector, include).await?;
401        self.runner
402            .double_tap_at_norm_coord(nx, ny)
403            .await
404            .map_err(|e| {
405                ExpectationFailure::new(FailureInit {
406                    code: Some(FailureCode::DriverError),
407                    message: format!("AndroidDriver::double_tap: {e}"),
408                    ..Default::default()
409                })
410            })
411    }
412
413    async fn long_press(
414        &self,
415        selector: &Selector,
416        duration: Duration,
417        include: Option<IncludeScope>,
418    ) -> Result<crate::PressTiming, ExpectationFailure> {
419        // Host-resolve + /long-press-at-norm-coord with
420        // duration. Kotlin uses UiDevice.swipe(x,y,x,y,steps) where
421        // steps = duration / 5ms to approximate a sustained press.
422        let (nx, ny) = resolve_with_implicit_wait(self, selector, include).await?;
423        let duration_ms = duration.as_millis() as u64;
424        // `UiDevice.swipe` reports nothing about when the touch was
425        // down, so the bounds are unavailable rather than guessed —
426        // `captureDuring` refuses on Android instead of handing back a
427        // frame it cannot place.
428        self.runner
429            .long_press_at_norm_coord(nx, ny, duration_ms)
430            .await
431            .map(|()| crate::PressTiming::unplaceable())
432            .map_err(|e| {
433                ExpectationFailure::new(FailureInit {
434                    code: Some(FailureCode::DriverError),
435                    message: format!("AndroidDriver::long_press: {e}"),
436                    ..Default::default()
437                })
438            })
439    }
440
441    /// Android honours `key-events` by skipping focus resolution.
442    ///
443    /// The iOS driver sends `Input-Dispatch-Mode` to its runner; there
444    /// is nothing to send here, because `/input-text` types into the
445    /// focused field either way. What the mode changes is whether this
446    /// driver resolves and taps the field first — and resolving is
447    /// exactly what fails for the callers who ask for this mode.
448    fn set_force_key_events(&mut self, force: bool) {
449        self.force_key_events = force;
450    }
451
452    async fn fill(
453        &self,
454        selector: &Selector,
455        text: &str,
456        include: Option<IncludeScope>,
457    ) -> Result<(), ExpectationFailure> {
458        if self.force_key_events {
459            // No resolve, no focus tap: type where focus already is.
460            // That is the whole mode — it exists for fields the tree
461            // cannot address, so resolving first would fail for exactly
462            // the callers who asked for it.
463            return self.runner.input_text(text).await.map_err(|e| {
464                ExpectationFailure::new(FailureInit {
465                    code: Some(FailureCode::DriverError),
466                    message: format!("AndroidDriver::fill (key-events): {e}"),
467                    ..Default::default()
468                })
469            });
470        }
471        // Host-resolve → tap to focus → /input-text. Mirror
472        // of swift FlyingFox /fill semantics (selector resolves; client
473        // types text into focused field).
474        let (nx, ny) = resolve_with_implicit_wait(self, selector, include).await?;
475        self.runner.tap_at_norm_coord(nx, ny).await.map_err(|e| {
476            ExpectationFailure::new(FailureInit {
477                code: Some(FailureCode::DriverError),
478                message: format!("AndroidDriver::fill: focus tap failed: {e}"),
479                ..Default::default()
480            })
481        })?;
482        self.runner.input_text(text).await.map_err(|e| {
483            ExpectationFailure::new(FailureInit {
484                code: Some(FailureCode::DriverError),
485                message: format!("AndroidDriver::fill: input_text failed: {e}"),
486                ..Default::default()
487            })
488        })
489    }
490
491    async fn clear(
492        &self,
493        selector: &Selector,
494        include: Option<IncludeScope>,
495    ) -> Result<(), ExpectationFailure> {
496        // Host-resolve → tap to focus → press DELETE N times.
497        // No Kotlin /clear endpoint needed (avoids UiObject2 fragility);
498        // 50 BACKSPACE presses cover near all real-world input fields.
499        let (nx, ny) = resolve_with_implicit_wait(self, selector, include).await?;
500        self.runner.tap_at_norm_coord(nx, ny).await.map_err(|e| {
501            ExpectationFailure::new(FailureInit {
502                code: Some(FailureCode::DriverError),
503                message: format!("AndroidDriver::clear: focus tap failed: {e}"),
504                ..Default::default()
505            })
506        })?;
507        for _ in 0..50 {
508            self.runner.press_key(KeyName::Delete).await.map_err(|e| {
509                ExpectationFailure::new(FailureInit {
510                    code: Some(FailureCode::DriverError),
511                    message: format!("AndroidDriver::clear: delete press failed: {e}"),
512                    ..Default::default()
513                })
514            })?;
515        }
516        Ok(())
517    }
518
519    async fn press_key(&self, key: KeyName) -> Result<(), ExpectationFailure> {
520        // Kotlin /press-key maps smix KeyName → KeyEvent.KEYCODE_*.
521        self.runner.press_key(key).await.map(|_| ()).map_err(|e| {
522            ExpectationFailure::new(FailureInit {
523                code: Some(FailureCode::DriverError),
524                message: format!("AndroidDriver::press_key: {e}"),
525                ..Default::default()
526            })
527        })
528    }
529
530    async fn scroll(
531        &self,
532        selector: &Selector,
533        direction: SwipeDirection,
534    ) -> Result<(), ExpectationFailure> {
535        // Host-side scroll-until-visible loop. /tree +
536        // /swipe-once primitives — same pattern as iOS.
537        let start = std::time::Instant::now();
538        let timeout = Duration::from_secs(20);
539        const MAX_SWIPES: u32 = 30;
540        for _ in 0..=MAX_SWIPES {
541            let tree = self.tree(None).await?;
542            if resolve_selector(&tree, selector).is_some() {
543                return Ok(());
544            }
545            if start.elapsed() > timeout {
546                break;
547            }
548            self.swipe_once(direction).await?;
549        }
550        Err(ExpectationFailure::new(FailureInit {
551            code: Some(FailureCode::ElementNotFound),
552            message: format!(
553                "AndroidDriver::scroll: element not visible after {} swipes: {}",
554                MAX_SWIPES,
555                describe_selector(selector)
556            ),
557            selector: Some(selector.clone()),
558            ..Default::default()
559        }))
560    }
561
562    async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), ExpectationFailure> {
563        self.runner.swipe_once(direction).await.map_err(|e| {
564            ExpectationFailure::new(FailureInit {
565                code: Some(FailureCode::DriverError),
566                message: format!("AndroidDriver::swipe_once: {e}"),
567                ..Default::default()
568            })
569        })
570    }
571
572    async fn swipe_at_norm_coord(
573        &self,
574        from: (f64, f64),
575        to: (f64, f64),
576    ) -> Result<(), ExpectationFailure> {
577        self.runner
578            .swipe_at_norm_coord(from, to)
579            .await
580            .map_err(|e| {
581                ExpectationFailure::new(FailureInit {
582                    code: Some(FailureCode::DriverError),
583                    message: format!("AndroidDriver::swipe_at_norm_coord: {e}"),
584                    ..Default::default()
585                })
586            })
587    }
588
589    async fn hide_keyboard(&self) -> Result<(), ExpectationFailure> {
590        self.runner.hide_keyboard().await.map_err(|e| {
591            ExpectationFailure::new(FailureInit {
592                code: Some(FailureCode::DriverError),
593                message: format!("AndroidDriver::hide_keyboard: {e}"),
594                ..Default::default()
595            })
596        })
597    }
598
599    async fn back(&self) -> Result<(), ExpectationFailure> {
600        // Kotlin /back → UiDevice.pressBack (KEYCODE_BACK).
601        self.runner.back().await.map_err(|e| {
602            ExpectationFailure::new(FailureInit {
603                code: Some(FailureCode::DriverError),
604                message: format!("AndroidDriver::back: {e}"),
605                ..Default::default()
606            })
607        })
608    }
609
610    async fn set_orientation(&self, orientation: Orientation) -> Result<(), ExpectationFailure> {
611        self.runner
612            .set_orientation(orientation.as_wire())
613            .await
614            .map_err(|e| {
615                ExpectationFailure::new(FailureInit {
616                    code: Some(FailureCode::DriverError),
617                    message: format!("AndroidDriver::set_orientation: {e}"),
618                    ..Default::default()
619                })
620            })
621    }
622
623    async fn foreground(&self, bundle_id: &str) -> Result<(), ExpectationFailure> {
624        // Kotlin /foreground runs `am start --activity-single-top
625        // -n pkg/.MainActivity` (mirror iOS XCUIDevice activate semantic
626        // without launching a new instance).
627        self.runner.foreground(bundle_id).await.map_err(|e| {
628            ExpectationFailure::new(FailureInit {
629                code: Some(FailureCode::DriverError),
630                message: format!("AndroidDriver::foreground: {e}"),
631                ..Default::default()
632            })
633        })
634    }
635
636    async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, ExpectationFailure> {
637        // The Kotlin runner's /webview-eval proxies to the app's shim on
638        // :28081 — the emulator's loopback is not the host's, so the
639        // direct-bridge method (which dials 127.0.0.1:28080 on the HOST)
640        // could never reach an Android app. This comment used to claim
641        // the proxy while the code dialed the host port.
642        self.runner.webview_eval_via_runner(js).await.map_err(|e| {
643            ExpectationFailure::new(FailureInit {
644                code: Some(FailureCode::DriverError),
645                message: format!("AndroidDriver::webview_eval: {e}"),
646                ..Default::default()
647            })
648        })
649    }
650}