1use std::ops::Deref;
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::sync::{Arc, Mutex};
4use std::{
5 fs,
6 io::{Read, Write},
7 marker::PhantomData,
8 path::Path,
9};
10
11use crate::proto::context_session_command::Command as ContextCommand;
12use crate::proto::context_session_event::Event as ContextEvent;
13use crate::proto::hook_completed_event::Result as HookCompletionResult;
14use crate::proto::register_hook_command::Hook as RegisterHook;
15use crate::proto::surface_session_command::Command as SurfaceCommand;
16use crate::proto::surface_session_event::Event as SurfaceEvent;
17use crate::proto::{
18 AccessibilitySnapshotCommand, AppLaunchedEvent, CaptureCommand, ClickElementCommand,
19 CloseDesktopWindowCommand, ConnectDesktopCommand, ConnectMobileCommand, ContextSessionCommand,
20 CountElementsCommand, DesktopAppLaunchedEvent, DesktopConnectedEvent,
21 DesktopPlatform as ProtoDesktopPlatform, DesktopWindowInfo, FillElementCommand,
22 FocusElementCommand, GetInnerTextCommand, GetTextContentCommand, LaunchAppCommand,
23 LaunchDesktopAppCommand, ListDesktopWindowsCommand, MobileConnectedEvent,
24 MobilePlatform as ProtoMobilePlatform, NavigatePageCommand, PressKeyCommand,
25 ReadFileChunkCommand, RegisterDesktopWindowHook, RegisterHookCommand,
26 RegisterMobileDownloadHook, RegisterMobileFileChooserHook, SaveMobileDownloadCommand,
27 ScreenshotCommand, SetMobileFileChooserFilesCommand, SurfaceSessionCommand,
28 UploadFileChunkCommand, WaitForHookCommand, WaitForSelectorCommand,
29};
30
31use super::hook::{DownloadHook, FileChooserHook};
32
33static MOBILE_TRANSFER_COUNTER: AtomicU64 = AtomicU64::new(1);
34
35pub trait AndroidHookType: private::Sealed + Clone + Send + Sync + 'static {
36 type Output;
37}
38
39mod private {
40 use super::*;
41 pub trait Sealed {
42 fn name() -> &'static str;
43 fn decode(
44 app: &NativeApp,
45 result: HookCompletionResult,
46 ) -> Result<<Self as AndroidHookType>::Output>
47 where
48 Self: AndroidHookType;
49 }
50}
51
52impl AndroidHookType for FileChooserHook {
53 type Output = AndroidFileChooser;
54}
55impl private::Sealed for FileChooserHook {
56 fn name() -> &'static str {
57 "file_chooser"
58 }
59 fn decode(app: &NativeApp, result: HookCompletionResult) -> Result<AndroidFileChooser> {
60 let HookCompletionResult::MobileFileChooser(result) = result else {
61 return Err(Error::new(
62 "Android file chooser hook returned an invalid result",
63 ));
64 };
65 Ok(AndroidFileChooser {
66 app: app.clone(),
67 id: result.file_chooser_id,
68 is_multiple: result.is_multiple,
69 })
70 }
71}
72impl AndroidHookType for DownloadHook {
73 type Output = AndroidDownload;
74}
75impl private::Sealed for DownloadHook {
76 fn name() -> &'static str {
77 "download"
78 }
79 fn decode(app: &NativeApp, result: HookCompletionResult) -> Result<AndroidDownload> {
80 let HookCompletionResult::MobileDownload(result) = result else {
81 return Err(Error::new(
82 "Android download hook returned an invalid result",
83 ));
84 };
85 Ok(AndroidDownload {
86 app: app.clone(),
87 id: result.download_id,
88 suggested_filename: result.suggested_filename,
89 })
90 }
91}
92
93pub struct AndroidHook<T: AndroidHookType> {
94 app: NativeApp,
95 id: String,
96 _type: PhantomData<T>,
97}
98
99#[derive(Clone)]
100pub struct AndroidFileChooser {
101 app: NativeApp,
102 id: String,
103 is_multiple: bool,
104}
105
106#[derive(Clone)]
107pub struct AndroidDownload {
108 app: NativeApp,
109 id: String,
110 suggested_filename: String,
111}
112use tokio::sync::{Mutex as AsyncMutex, mpsc};
113use tokio_stream::wrappers::ReceiverStream;
114
115use super::command::command_retry_options;
116use super::runtime::get_runtime;
117use super::types::{
118 AccessibilitySnapshotFormat, AccessibilitySnapshotMode, AccessibilitySnapshotOptions,
119 BoundingBox, ClickOptions, CommandOptions, Error, PressOptions, Result, RuntimeClient,
120 ScreenshotOptions, TextResult, WaitForSelectorOptions,
121};
122use super::web_locators::{RoleOptions, TextMatcher, TextOptions};
123
124#[derive(Debug, Clone, Default)]
125pub struct MobileAndroidConnectOptions {
126 pub device: Option<String>,
127 pub adb_endpoint: Option<String>,
128 pub preserve_app_state: bool,
129 pub timeout_ms: Option<u32>,
130}
131
132#[derive(Debug, Clone, Default)]
133pub struct MobileAndroidLaunchOptions {
134 pub apk_path: Option<String>,
135 pub app_id: Option<String>,
136 pub launch_activity: Option<String>,
137 pub stop_before_launch: bool,
138 pub timeout_ms: Option<u32>,
139}
140
141#[derive(Debug, Clone, Default)]
142pub struct MobileIosConnectOptions {
143 pub device: Option<String>,
144 pub agent_endpoint: Option<String>,
145 pub preserve_app_state: bool,
146 pub timeout_ms: Option<u32>,
147}
148
149#[derive(Debug, Clone, Default)]
150pub struct MobileIosLaunchOptions {
151 pub app_path: Option<String>,
152 pub app_id: Option<String>,
153 pub stop_before_launch: bool,
154 pub timeout_ms: Option<u32>,
155}
156
157#[derive(Debug, Clone, Default)]
158pub struct DesktopMacConnectOptions {
159 pub agent_endpoint: Option<String>,
160 pub timeout_ms: Option<u32>,
161}
162
163#[derive(Debug, Clone)]
164pub struct DesktopMacLaunchOptions {
165 pub app_id: String,
166 pub terminate_running: bool,
167 pub timeout_ms: Option<u32>,
168}
169
170pub type DesktopWindowsConnectOptions = DesktopMacConnectOptions;
171pub type DesktopWindowsLaunchOptions = DesktopMacLaunchOptions;
172
173#[derive(Clone)]
174pub struct DesktopApp {
175 app: NativeApp,
176}
177
178#[derive(Clone)]
179pub struct DesktopWindow {
180 app: NativeApp,
181 window_id: String,
182 title: String,
183 is_main: bool,
184}
185
186#[derive(Clone, Copy)]
187pub struct WindowHook;
188pub const WINDOW: WindowHook = WindowHook;
189
190pub struct DesktopWindowHook {
191 app: DesktopApp,
192 id: String,
193}
194
195pub type MacApp = DesktopApp;
196pub type MacLocator = NativeLocator;
197pub type WindowsApp = DesktopApp;
198pub type WindowsLocator = NativeLocator;
199
200#[derive(Clone)]
201pub struct MacDesktop {
202 inner: AndroidDevice,
203}
204pub type WindowsDesktop = MacDesktop;
205
206pub type IosApp = NativeApp;
207pub type IosLocator = NativeLocator;
208
209#[derive(Clone)]
210pub struct IosDevice {
211 inner: AndroidDevice,
212}
213
214#[derive(Clone)]
215pub struct NativeLocator {
216 page: NativeApp,
217 selector: String,
218}
219
220#[derive(Clone)]
221pub struct NativeApp {
222 inner: Arc<NativeAppInner>,
223}
224
225pub type AndroidApp = NativeApp;
226pub type AndroidLocator = NativeLocator;
227
228#[derive(Clone)]
229pub struct AndroidDevice {
230 inner: Arc<AndroidDeviceInner>,
231}
232
233struct AndroidDeviceInner {
234 runtime: Arc<RuntimeClient>,
235 state: AsyncMutex<AndroidDeviceState>,
236 session_id: String,
237 initial_app: NativeApp,
238 current_app: Mutex<NativeApp>,
239}
240
241struct AndroidDeviceState {
242 command_tx: mpsc::Sender<SurfaceSessionCommand>,
243 events: tonic::Streaming<crate::proto::SurfaceSessionEvent>,
244 closed: bool,
245}
246
247struct NativeAppInner {
248 runtime: Arc<RuntimeClient>,
249 surface_session_id: String,
250 session_id: String,
251 state: AsyncMutex<NativeAppState>,
252}
253
254#[derive(Default)]
255struct NativeAppState {
256 handle: Option<AndroidTabHandle>,
257}
258
259struct AndroidTabHandle {
260 command_tx: mpsc::Sender<crate::proto::ContextSessionCommand>,
261 events: tonic::Streaming<crate::proto::ContextSessionEvent>,
262 closed: bool,
263}
264
265pub mod android {
266 use super::*;
267
268 pub async fn connect(options: MobileAndroidConnectOptions) -> Result<AndroidDevice> {
269 let runtime = get_runtime().await?;
270 let mut engine = runtime.engine.clone();
271 let (command_tx, command_rx) = mpsc::channel(16);
272 let response = engine
273 .surface_session(tonic::Request::new(ReceiverStream::new(command_rx)))
274 .await?;
275 let mut events = response.into_inner();
276
277 command_tx
278 .send(SurfaceSessionCommand {
279 command: Some(SurfaceCommand::ConnectMobile(ConnectMobileCommand {
280 platform: ProtoMobilePlatform::Android as i32,
281 device: options.device,
282 adb_endpoint: options.adb_endpoint,
283 preserve_app_state: options.preserve_app_state,
284 retry_options: command_retry_options(options.timeout_ms),
285 })),
286 })
287 .await
288 .map_err(|_| Error::new("failed to send ConnectMobileCommand"))?;
289
290 loop {
291 let event = events.message().await?.ok_or_else(|| {
292 Error::new("surface session closed before mobile connect response")
293 })?;
294
295 match event.event {
296 Some(SurfaceEvent::MobileConnected(MobileConnectedEvent {
297 initial_app_session_id,
298 device_session_id,
299 ..
300 })) => {
301 let initial_app = NativeApp {
302 inner: Arc::new(NativeAppInner {
303 runtime: Arc::clone(&runtime),
304 surface_session_id: event.session_id.clone(),
305 session_id: initial_app_session_id,
306 state: AsyncMutex::new(NativeAppState::default()),
307 }),
308 };
309 return Ok(AndroidDevice {
310 inner: Arc::new(AndroidDeviceInner {
311 runtime,
312 state: AsyncMutex::new(AndroidDeviceState {
313 command_tx,
314 events,
315 closed: false,
316 }),
317 session_id: if device_session_id.is_empty() {
318 event.session_id
319 } else {
320 device_session_id
321 },
322 initial_app: initial_app.clone(),
323 current_app: Mutex::new(initial_app),
324 }),
325 });
326 }
327 Some(SurfaceEvent::Error(error)) => {
328 return Err(Error::new(format!(
329 "surface session error during mobile connect: {}",
330 error.message
331 )));
332 }
333 _ => {}
334 }
335 }
336 }
337}
338
339pub mod ios {
340 use super::*;
341
342 pub async fn connect(options: MobileIosConnectOptions) -> Result<IosDevice> {
343 let runtime = get_runtime().await?;
344 let mut engine = runtime.engine.clone();
345 let (command_tx, command_rx) = mpsc::channel(16);
346 let response = engine
347 .surface_session(tonic::Request::new(ReceiverStream::new(command_rx)))
348 .await?;
349 let mut events = response.into_inner();
350
351 command_tx
352 .send(SurfaceSessionCommand {
353 command: Some(SurfaceCommand::ConnectMobile(ConnectMobileCommand {
354 platform: ProtoMobilePlatform::Ios as i32,
355 device: options.device,
356 adb_endpoint: options.agent_endpoint,
359 preserve_app_state: options.preserve_app_state,
360 retry_options: command_retry_options(options.timeout_ms),
361 })),
362 })
363 .await
364 .map_err(|_| Error::new("failed to send iOS ConnectMobileCommand"))?;
365
366 loop {
367 let event = events
368 .message()
369 .await?
370 .ok_or_else(|| Error::new("surface session closed before iOS connect response"))?;
371 match event.event {
372 Some(SurfaceEvent::MobileConnected(MobileConnectedEvent {
373 initial_app_session_id,
374 device_session_id,
375 ..
376 })) => {
377 let initial_app = NativeApp {
378 inner: Arc::new(NativeAppInner {
379 runtime: Arc::clone(&runtime),
380 surface_session_id: event.session_id.clone(),
381 session_id: initial_app_session_id,
382 state: AsyncMutex::new(NativeAppState::default()),
383 }),
384 };
385 return Ok(IosDevice {
386 inner: AndroidDevice {
387 inner: Arc::new(AndroidDeviceInner {
388 runtime,
389 state: AsyncMutex::new(AndroidDeviceState {
390 command_tx,
391 events,
392 closed: false,
393 }),
394 session_id: if device_session_id.is_empty() {
395 event.session_id
396 } else {
397 device_session_id
398 },
399 initial_app: initial_app.clone(),
400 current_app: Mutex::new(initial_app),
401 }),
402 },
403 });
404 }
405 Some(SurfaceEvent::Error(error)) => {
406 return Err(Error::new(format!(
407 "surface session error during iOS connect: {}",
408 error.message
409 )));
410 }
411 _ => {}
412 }
413 }
414 }
415}
416
417pub mod desktop {
418 use super::*;
419 pub use super::{
420 DesktopApp, DesktopMacConnectOptions, DesktopMacLaunchOptions, DesktopWindow,
421 DesktopWindowHook, DesktopWindowsConnectOptions, DesktopWindowsLaunchOptions, MacApp,
422 MacDesktop, MacLocator, WINDOW, WindowHook, WindowsApp, WindowsDesktop, WindowsLocator,
423 };
424
425 pub mod mac {
426 use super::*;
427
428 pub async fn connect(options: DesktopMacConnectOptions) -> Result<MacDesktop> {
429 let runtime = get_runtime().await?;
430 let mut engine = runtime.engine.clone();
431 let (command_tx, command_rx) = mpsc::channel(16);
432 let response = engine
433 .surface_session(tonic::Request::new(ReceiverStream::new(command_rx)))
434 .await?;
435 let mut events = response.into_inner();
436 command_tx
437 .send(SurfaceSessionCommand {
438 command: Some(SurfaceCommand::ConnectDesktop(ConnectDesktopCommand {
439 platform: ProtoDesktopPlatform::Mac as i32,
440 agent_endpoint: options.agent_endpoint,
441 retry_options: command_retry_options(options.timeout_ms),
442 })),
443 })
444 .await
445 .map_err(|_| Error::new("failed to send ConnectDesktopCommand"))?;
446 loop {
447 let event = events.message().await?.ok_or_else(|| {
448 Error::new("surface session closed before macOS desktop connect response")
449 })?;
450 match event.event {
451 Some(SurfaceEvent::DesktopConnected(DesktopConnectedEvent {
452 initial_app_session_id,
453 desktop_session_id,
454 ..
455 })) => {
456 let initial_app = NativeApp {
457 inner: Arc::new(NativeAppInner {
458 runtime: Arc::clone(&runtime),
459 surface_session_id: event.session_id.clone(),
460 session_id: initial_app_session_id,
461 state: AsyncMutex::new(NativeAppState::default()),
462 }),
463 };
464 return Ok(MacDesktop {
465 inner: AndroidDevice {
466 inner: Arc::new(AndroidDeviceInner {
467 runtime,
468 state: AsyncMutex::new(AndroidDeviceState {
469 command_tx,
470 events,
471 closed: false,
472 }),
473 session_id: if desktop_session_id.is_empty() {
474 event.session_id
475 } else {
476 desktop_session_id
477 },
478 initial_app: initial_app.clone(),
479 current_app: Mutex::new(initial_app),
480 }),
481 },
482 });
483 }
484 Some(SurfaceEvent::Error(error)) => return Err(Error::new(error.message)),
485 _ => {}
486 }
487 }
488 }
489 }
490
491 pub mod windows {
492 use super::*;
493
494 pub async fn connect(options: DesktopWindowsConnectOptions) -> Result<WindowsDesktop> {
495 let runtime = get_runtime().await?;
496 let mut engine = runtime.engine.clone();
497 let (command_tx, command_rx) = mpsc::channel(16);
498 let response = engine
499 .surface_session(tonic::Request::new(ReceiverStream::new(command_rx)))
500 .await?;
501 let mut events = response.into_inner();
502 command_tx
503 .send(SurfaceSessionCommand {
504 command: Some(SurfaceCommand::ConnectDesktop(ConnectDesktopCommand {
505 platform: ProtoDesktopPlatform::Windows as i32,
506 agent_endpoint: options.agent_endpoint,
507 retry_options: command_retry_options(options.timeout_ms),
508 })),
509 })
510 .await
511 .map_err(|_| Error::new("failed to send ConnectDesktopCommand"))?;
512 loop {
513 let event = events.message().await?.ok_or_else(|| {
514 Error::new("surface session closed before Windows desktop connect response")
515 })?;
516 match event.event {
517 Some(SurfaceEvent::DesktopConnected(DesktopConnectedEvent {
518 initial_app_session_id,
519 desktop_session_id,
520 ..
521 })) => {
522 let initial_app = NativeApp {
523 inner: Arc::new(NativeAppInner {
524 runtime: Arc::clone(&runtime),
525 surface_session_id: event.session_id.clone(),
526 session_id: initial_app_session_id,
527 state: AsyncMutex::new(NativeAppState::default()),
528 }),
529 };
530 return Ok(MacDesktop {
531 inner: AndroidDevice {
532 inner: Arc::new(AndroidDeviceInner {
533 runtime,
534 state: AsyncMutex::new(AndroidDeviceState {
535 command_tx,
536 events,
537 closed: false,
538 }),
539 session_id: if desktop_session_id.is_empty() {
540 event.session_id
541 } else {
542 desktop_session_id
543 },
544 initial_app: initial_app.clone(),
545 current_app: Mutex::new(initial_app),
546 }),
547 },
548 });
549 }
550 Some(SurfaceEvent::Error(error)) => return Err(Error::new(error.message)),
551 _ => {}
552 }
553 }
554 }
555 }
556}
557
558impl MacDesktop {
559 pub fn session_id(&self) -> &str {
560 self.inner.session_id()
561 }
562
563 pub fn app(&self) -> MacApp {
564 DesktopApp {
565 app: self.inner.app(),
566 }
567 }
568
569 pub async fn launch(&self, options: DesktopMacLaunchOptions) -> Result<MacApp> {
570 let mut state = self.inner.inner.state.lock().await;
571 ensure_android_device_open(&state, &self.inner.inner.session_id)?;
572 state
573 .command_tx
574 .send(SurfaceSessionCommand {
575 command: Some(SurfaceCommand::LaunchDesktopApp(LaunchDesktopAppCommand {
576 app_id: options.app_id,
577 terminate_running: options.terminate_running,
578 retry_options: command_retry_options(options.timeout_ms),
579 })),
580 })
581 .await
582 .map_err(|_| Error::new("failed to send LaunchDesktopAppCommand"))?;
583 loop {
584 let event = state.events.message().await?.ok_or_else(|| {
585 Error::new("surface session closed before macOS app launch response")
586 })?;
587 match event.event {
588 Some(SurfaceEvent::DesktopAppLaunched(DesktopAppLaunchedEvent {
589 app_session_id,
590 ..
591 })) => {
592 let app = NativeApp {
593 inner: Arc::new(NativeAppInner {
594 runtime: Arc::clone(&self.inner.inner.runtime),
595 surface_session_id: event.session_id,
596 session_id: app_session_id,
597 state: AsyncMutex::new(NativeAppState::default()),
598 }),
599 };
600 if let Ok(mut current_app) = self.inner.inner.current_app.lock() {
601 *current_app = app.clone();
602 }
603 return Ok(DesktopApp { app });
604 }
605 Some(SurfaceEvent::Error(error)) => return Err(Error::new(error.message)),
606 _ => {}
607 }
608 }
609 }
610}
611
612impl Deref for DesktopApp {
613 type Target = NativeApp;
614 fn deref(&self) -> &Self::Target {
615 &self.app
616 }
617}
618
619impl Deref for DesktopWindow {
620 type Target = NativeApp;
621 fn deref(&self) -> &Self::Target {
622 &self.app
623 }
624}
625
626impl DesktopApp {
627 pub async fn windows(&self) -> Result<Vec<DesktopWindow>> {
628 let mut state = self.app.inner.state.lock().await;
629 let handle = self.app.ensure_handle(&mut state).await?;
630 ensure_android_app_open(handle, &self.app.inner.session_id)?;
631 handle
632 .command_tx
633 .send(ContextSessionCommand {
634 surface_session_id: self.app.inner.surface_session_id.clone(),
635 context_session_id: self.app.inner.session_id.clone(),
636 command: Some(ContextCommand::ListDesktopWindows(
637 ListDesktopWindowsCommand {},
638 )),
639 })
640 .await
641 .map_err(|_| Error::new("failed to send ListDesktopWindowsCommand"))?;
642 loop {
643 let event =
644 handle.events.message().await?.ok_or_else(|| {
645 Error::new("desktop app session closed while listing windows")
646 })?;
647 match event.event {
648 Some(ContextEvent::DesktopWindowsResolved(resolved)) => {
649 return resolved
650 .windows
651 .into_iter()
652 .map(|info| self.window(info))
653 .collect();
654 }
655 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
656 _ => {}
657 }
658 }
659 }
660
661 pub async fn main_window(&self) -> Result<DesktopWindow> {
662 self.windows()
663 .await?
664 .into_iter()
665 .find(|window| window.is_main)
666 .ok_or_else(|| {
667 Error::new("desktop application does not currently expose a main window")
668 })
669 }
670
671 pub async fn register_hook(&self, _hook_type: WindowHook) -> Result<DesktopWindowHook> {
672 let mut state = self.app.inner.state.lock().await;
673 let handle = self.app.ensure_handle(&mut state).await?;
674 ensure_android_app_open(handle, &self.app.inner.session_id)?;
675 handle
676 .command_tx
677 .send(ContextSessionCommand {
678 surface_session_id: self.app.inner.surface_session_id.clone(),
679 context_session_id: self.app.inner.session_id.clone(),
680 command: Some(ContextCommand::RegisterHook(RegisterHookCommand {
681 hook: Some(RegisterHook::DesktopWindow(RegisterDesktopWindowHook {})),
682 })),
683 })
684 .await
685 .map_err(|_| Error::new("failed to send desktop RegisterHookCommand"))?;
686 loop {
687 let event = handle.events.message().await?.ok_or_else(|| {
688 Error::new("desktop app session closed while registering window hook")
689 })?;
690 match event.event {
691 Some(ContextEvent::HookRegistered(event)) => {
692 return Ok(DesktopWindowHook {
693 app: self.clone(),
694 id: event.hook_id,
695 });
696 }
697 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
698 _ => {}
699 }
700 }
701 }
702
703 fn window(&self, info: DesktopWindowInfo) -> Result<DesktopWindow> {
704 if info.context_session_id.is_empty() || info.window_id.is_empty() {
705 return Err(Error::new(
706 "desktop window result is missing its context or window id",
707 ));
708 }
709 Ok(DesktopWindow {
710 app: NativeApp {
711 inner: Arc::new(NativeAppInner {
712 runtime: Arc::clone(&self.app.inner.runtime),
713 surface_session_id: self.app.inner.surface_session_id.clone(),
714 session_id: info.context_session_id,
715 state: AsyncMutex::new(NativeAppState::default()),
716 }),
717 },
718 window_id: info.window_id,
719 title: info.title,
720 is_main: info.is_main,
721 })
722 }
723}
724
725impl DesktopWindow {
726 pub fn window_id(&self) -> &str {
727 &self.window_id
728 }
729 pub fn title(&self) -> &str {
730 &self.title
731 }
732 pub fn is_main(&self) -> bool {
733 self.is_main
734 }
735
736 pub async fn close(&self) -> Result<()> {
737 let mut state = self.app.inner.state.lock().await;
738 let handle = self.app.ensure_handle(&mut state).await?;
739 handle
740 .command_tx
741 .send(ContextSessionCommand {
742 surface_session_id: self.app.inner.surface_session_id.clone(),
743 context_session_id: self.app.inner.session_id.clone(),
744 command: Some(ContextCommand::CloseDesktopWindow(
745 CloseDesktopWindowCommand {},
746 )),
747 })
748 .await
749 .map_err(|_| Error::new("failed to send CloseDesktopWindowCommand"))?;
750 loop {
751 let event =
752 handle.events.message().await?.ok_or_else(|| {
753 Error::new("desktop window session closed while closing window")
754 })?;
755 match event.event {
756 Some(ContextEvent::DesktopWindowClosed(event))
757 if event.window_id == self.window_id =>
758 {
759 return Ok(());
760 }
761 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
762 _ => {}
763 }
764 }
765 }
766}
767
768impl DesktopWindowHook {
769 pub fn id(&self) -> &str {
770 &self.id
771 }
772 pub async fn wait(&self) -> Result<DesktopWindow> {
773 self.wait_with_options(CommandOptions::default()).await
774 }
775 pub async fn wait_with_options(&self, options: CommandOptions) -> Result<DesktopWindow> {
776 let mut state = self.app.app.inner.state.lock().await;
777 let handle = self.app.app.ensure_handle(&mut state).await?;
778 handle
779 .command_tx
780 .send(ContextSessionCommand {
781 surface_session_id: self.app.app.inner.surface_session_id.clone(),
782 context_session_id: self.app.app.inner.session_id.clone(),
783 command: Some(ContextCommand::WaitForHook(WaitForHookCommand {
784 hook_id: self.id.clone(),
785 retry_options: command_retry_options(options.timeout_ms),
786 })),
787 })
788 .await
789 .map_err(|_| Error::new("failed to send desktop WaitForHookCommand"))?;
790 loop {
791 let event = handle.events.message().await?.ok_or_else(|| {
792 Error::new("desktop app session closed while waiting for window hook")
793 })?;
794 match event.event {
795 Some(ContextEvent::HookCompleted(event)) if event.hook_id == self.id => {
796 let Some(HookCompletionResult::DesktopWindow(result)) = event.result else {
797 return Err(Error::new("desktop window hook returned an invalid result"));
798 };
799 return self.app.window(
800 result
801 .window
802 .ok_or_else(|| Error::new("desktop window hook returned no window"))?,
803 );
804 }
805 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
806 _ => {}
807 }
808 }
809 }
810}
811
812impl IosDevice {
813 pub fn session_id(&self) -> &str {
814 self.inner.session_id()
815 }
816
817 pub fn app(&self) -> IosApp {
818 self.inner.app()
819 }
820
821 pub fn initial_app(&self) -> IosApp {
822 self.inner.initial_app()
823 }
824
825 pub async fn launch(&self, options: MobileIosLaunchOptions) -> Result<IosApp> {
826 self.inner
827 .launch(MobileAndroidLaunchOptions {
828 apk_path: options.app_path,
829 app_id: options.app_id,
830 stop_before_launch: options.stop_before_launch,
831 timeout_ms: options.timeout_ms,
832 ..MobileAndroidLaunchOptions::default()
833 })
834 .await
835 }
836}
837
838impl AndroidDevice {
839 pub fn session_id(&self) -> &str {
840 &self.inner.session_id
841 }
842
843 pub fn app(&self) -> NativeApp {
844 self.inner
845 .current_app
846 .lock()
847 .map(|app| app.clone())
848 .unwrap_or_else(|_| self.inner.initial_app.clone())
849 }
850
851 pub fn initial_app(&self) -> NativeApp {
852 self.inner.initial_app.clone()
853 }
854
855 pub async fn launch(&self, options: MobileAndroidLaunchOptions) -> Result<NativeApp> {
856 let mut state = self.inner.state.lock().await;
857 ensure_android_device_open(&state, &self.inner.session_id)?;
858
859 state
860 .command_tx
861 .send(SurfaceSessionCommand {
862 command: Some(SurfaceCommand::LaunchApp(LaunchAppCommand {
863 apk_path: options.apk_path,
864 app_id: options.app_id,
865 launch_activity: options.launch_activity,
866 stop_before_launch: options.stop_before_launch,
867 retry_options: command_retry_options(options.timeout_ms),
868 })),
869 })
870 .await
871 .map_err(|_| Error::new("failed to send LaunchAppCommand"))?;
872
873 loop {
874 let event =
875 state.events.message().await?.ok_or_else(|| {
876 Error::new("surface session closed before app launch response")
877 })?;
878
879 match event.event {
880 Some(SurfaceEvent::AppLaunched(AppLaunchedEvent { app_session_id, .. })) => {
881 let app = NativeApp {
882 inner: Arc::new(NativeAppInner {
883 runtime: Arc::clone(&self.inner.runtime),
884 surface_session_id: event.session_id,
885 session_id: app_session_id,
886 state: AsyncMutex::new(NativeAppState::default()),
887 }),
888 };
889 if let Ok(mut current_app) = self.inner.current_app.lock() {
890 *current_app = app.clone();
891 }
892 return Ok(app);
893 }
894 Some(SurfaceEvent::Error(error)) => {
895 return Err(Error::new(format!(
896 "surface session error while launching Android app: {}",
897 error.message
898 )));
899 }
900 Some(SurfaceEvent::Closed(_)) => {
901 state.closed = true;
902 return Err(Error::new(
903 "surface session closed while waiting for Android app launch",
904 ));
905 }
906 _ => {}
907 }
908 }
909 }
910}
911
912impl NativeApp {
913 pub fn session_id(&self) -> &str {
914 &self.inner.session_id
915 }
916
917 pub async fn register_hook<T: AndroidHookType>(&self, _hook_type: T) -> Result<AndroidHook<T>> {
918 let mut state = self.inner.state.lock().await;
919 let handle = self.ensure_handle(&mut state).await?;
920 ensure_android_app_open(handle, &self.inner.session_id)?;
921 let hook = match T::name() {
922 "file_chooser" => Some(RegisterHook::MobileFileChooser(
923 RegisterMobileFileChooserHook {},
924 )),
925 "download" => Some(RegisterHook::MobileDownload(RegisterMobileDownloadHook {})),
926 _ => return Err(Error::new("hook type is not supported by Android apps")),
927 };
928 handle
929 .command_tx
930 .send(ContextSessionCommand {
931 surface_session_id: self.inner.surface_session_id.clone(),
932 context_session_id: self.inner.session_id.clone(),
933 command: Some(ContextCommand::RegisterHook(RegisterHookCommand { hook })),
934 })
935 .await
936 .map_err(|_| Error::new("failed to send Android RegisterHookCommand"))?;
937 loop {
938 let event =
939 handle.events.message().await?.ok_or_else(|| {
940 Error::new("Android app session closed while registering hook")
941 })?;
942 match event.event {
943 Some(ContextEvent::HookRegistered(event)) => {
944 return Ok(AndroidHook {
945 app: self.clone(),
946 id: event.hook_id,
947 _type: PhantomData,
948 });
949 }
950 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
951 _ => {}
952 }
953 }
954 }
955
956 pub fn locator(&self, selector: impl Into<String>) -> NativeLocator {
957 NativeLocator {
958 page: self.clone(),
959 selector: normalize_mobile_selector_for_transport(&selector.into()),
960 }
961 }
962
963 pub fn get_by_role(&self, role: impl Into<String>) -> NativeLocator {
964 self.get_by_role_with_options(role, RoleOptions::default())
965 }
966
967 pub fn get_by_role_with_options(
968 &self,
969 role: impl Into<String>,
970 options: RoleOptions,
971 ) -> NativeLocator {
972 let mut spec = serde_json::to_value(options).expect("role options");
973 spec["kind"] = serde_json::json!("role");
974 spec["role"] = serde_json::json!(role.into());
975 self.locator(mobile_semantic_selector(spec))
976 }
977
978 pub fn get_by_text(&self, text: impl Into<TextMatcher>) -> NativeLocator {
979 self.get_by_text_with_options(text, TextOptions::default())
980 }
981
982 pub fn get_by_text_with_options(
983 &self,
984 text: impl Into<TextMatcher>,
985 options: TextOptions,
986 ) -> NativeLocator {
987 self.locator(mobile_text_selector("text", text.into(), options))
988 }
989
990 pub fn get_by_label(&self, text: impl Into<TextMatcher>) -> NativeLocator {
991 self.get_by_label_with_options(text, TextOptions::default())
992 }
993
994 pub fn get_by_label_with_options(
995 &self,
996 text: impl Into<TextMatcher>,
997 options: TextOptions,
998 ) -> NativeLocator {
999 self.locator(mobile_text_selector("label", text.into(), options))
1000 }
1001
1002 pub fn get_by_test_id(&self, text: impl Into<TextMatcher>) -> NativeLocator {
1003 self.locator(mobile_semantic_selector(
1004 serde_json::json!({"kind":"testId", "text":text.into()}),
1005 ))
1006 }
1007
1008 pub async fn goto(&self, url: &str, options: CommandOptions) -> Result<()> {
1009 self.navigate(url, options).await
1010 }
1011
1012 pub async fn navigate(&self, url: &str, options: CommandOptions) -> Result<()> {
1013 let mut state = self.inner.state.lock().await;
1014 let handle = self.ensure_handle(&mut state).await?;
1015 ensure_android_app_open(handle, &self.inner.session_id)?;
1016 handle
1017 .command_tx
1018 .send(ContextSessionCommand {
1019 surface_session_id: self.inner.surface_session_id.clone(),
1020 context_session_id: self.inner.session_id.clone(),
1021 command: Some(ContextCommand::Navigate(NavigatePageCommand {
1022 url: url.to_string(),
1023 retry_options: command_retry_options(options.timeout_ms),
1024 })),
1025 })
1026 .await
1027 .map_err(|_| Error::new("failed to send mobile NavigatePageCommand"))?;
1028 loop {
1029 let event =
1030 handle.events.message().await?.ok_or_else(|| {
1031 Error::new("native app session closed while opening deep link")
1032 })?;
1033 match event.event {
1034 Some(ContextEvent::Navigated(_)) => return Ok(()),
1035 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1036 Some(ContextEvent::Closed(_)) => {
1037 handle.closed = true;
1038 return Err(Error::new(
1039 "native app session closed while opening deep link",
1040 ));
1041 }
1042 _ => {}
1043 }
1044 }
1045 }
1046
1047 pub async fn click(&self, selector: &str, options: CommandOptions) -> Result<()> {
1048 self.click_with_options(selector, options.into()).await
1049 }
1050
1051 pub async fn click_with_options(&self, selector: &str, options: ClickOptions) -> Result<()> {
1052 let selector = normalize_mobile_selector_for_transport(selector);
1053 let mut state = self.inner.state.lock().await;
1054 let handle = self.ensure_handle(&mut state).await?;
1055 ensure_android_app_open(handle, &self.inner.session_id)?;
1056
1057 handle
1058 .command_tx
1059 .send(ContextSessionCommand {
1060 surface_session_id: self.inner.surface_session_id.clone(),
1061 context_session_id: self.inner.session_id.clone(),
1062 command: Some(ContextCommand::ClickElement(ClickElementCommand {
1063 css_selector: selector.clone(),
1064 retry_options: command_retry_options(options.timeout_ms),
1065 button: Some(options.button.as_str().to_string()),
1066 click_count: options.click_count,
1067 })),
1068 })
1069 .await
1070 .map_err(|_| Error::new("failed to send ClickElementCommand"))?;
1071
1072 loop {
1073 let event =
1074 handle.events.message().await?.ok_or_else(|| {
1075 Error::new("app session closed while waiting for click result")
1076 })?;
1077
1078 match event.event {
1079 Some(ContextEvent::Attached(_)) => {}
1080 Some(ContextEvent::ElementClicked(_)) => return Ok(()),
1081 Some(ContextEvent::Error(error)) => {
1082 return Err(Error::new(format!(
1083 "app session error while clicking Android locator {:?}: {}",
1084 selector, error.message,
1085 )));
1086 }
1087 Some(ContextEvent::Closed(_)) => {
1088 handle.closed = true;
1089 return Err(Error::new(format!(
1090 "app session {} closed while waiting for click result",
1091 self.inner.session_id
1092 )));
1093 }
1094 _ => {}
1095 }
1096 }
1097 }
1098
1099 pub async fn fill(&self, selector: &str, value: &str, options: CommandOptions) -> Result<()> {
1100 let selector = normalize_mobile_selector_for_transport(selector);
1101 let mut state = self.inner.state.lock().await;
1102 let handle = self.ensure_handle(&mut state).await?;
1103 ensure_android_app_open(handle, &self.inner.session_id)?;
1104
1105 handle
1106 .command_tx
1107 .send(ContextSessionCommand {
1108 surface_session_id: self.inner.surface_session_id.clone(),
1109 context_session_id: self.inner.session_id.clone(),
1110 command: Some(ContextCommand::FillElement(FillElementCommand {
1111 css_selector: selector.clone(),
1112 value: value.to_string(),
1113 retry_options: command_retry_options(options.timeout_ms),
1114 })),
1115 })
1116 .await
1117 .map_err(|_| Error::new("failed to send FillElementCommand"))?;
1118
1119 loop {
1120 let event =
1121 handle.events.message().await?.ok_or_else(|| {
1122 Error::new("app session closed while waiting for fill result")
1123 })?;
1124
1125 match event.event {
1126 Some(ContextEvent::Attached(_)) => {}
1127 Some(ContextEvent::ElementFilled(_)) => return Ok(()),
1128 Some(ContextEvent::Error(error)) => {
1129 return Err(Error::new(format!(
1130 "app session error while filling Android locator {:?}: {}",
1131 selector, error.message,
1132 )));
1133 }
1134 Some(ContextEvent::Closed(_)) => {
1135 handle.closed = true;
1136 return Err(Error::new(format!(
1137 "app session {} closed while waiting for fill result",
1138 self.inner.session_id
1139 )));
1140 }
1141 _ => {}
1142 }
1143 }
1144 }
1145
1146 pub async fn count(&self, selector: &str, options: CommandOptions) -> Result<u32> {
1147 let selector = normalize_mobile_selector_for_transport(selector);
1148 let mut state = self.inner.state.lock().await;
1149 let handle = self.ensure_handle(&mut state).await?;
1150 ensure_android_app_open(handle, &self.inner.session_id)?;
1151
1152 handle
1153 .command_tx
1154 .send(ContextSessionCommand {
1155 surface_session_id: self.inner.surface_session_id.clone(),
1156 context_session_id: self.inner.session_id.clone(),
1157 command: Some(ContextCommand::CountElements(CountElementsCommand {
1158 css_selector: selector.clone(),
1159 retry_options: command_retry_options(options.timeout_ms),
1160 })),
1161 })
1162 .await
1163 .map_err(|_| Error::new("failed to send CountElementsCommand"))?;
1164
1165 loop {
1166 let event =
1167 handle.events.message().await?.ok_or_else(|| {
1168 Error::new("app session closed while waiting for count result")
1169 })?;
1170
1171 match event.event {
1172 Some(ContextEvent::Attached(_)) => {}
1173 Some(ContextEvent::ElementCounted(counted)) => return Ok(counted.count),
1174 Some(ContextEvent::Error(error)) => {
1175 return Err(Error::new(format!(
1176 "app session error while counting Android locator {:?}: {}",
1177 selector, error.message,
1178 )));
1179 }
1180 Some(ContextEvent::Closed(_)) => {
1181 handle.closed = true;
1182 return Err(Error::new(format!(
1183 "app session {} closed while waiting for count result",
1184 self.inner.session_id
1185 )));
1186 }
1187 _ => {}
1188 }
1189 }
1190 }
1191
1192 pub async fn focus(&self, selector: &str, options: CommandOptions) -> Result<()> {
1193 let selector = normalize_mobile_selector_for_transport(selector);
1194 let mut state = self.inner.state.lock().await;
1195 let handle = self.ensure_handle(&mut state).await?;
1196 ensure_android_app_open(handle, &self.inner.session_id)?;
1197
1198 handle
1199 .command_tx
1200 .send(ContextSessionCommand {
1201 surface_session_id: self.inner.surface_session_id.clone(),
1202 context_session_id: self.inner.session_id.clone(),
1203 command: Some(ContextCommand::FocusElement(FocusElementCommand {
1204 css_selector: selector.clone(),
1205 retry_options: command_retry_options(options.timeout_ms),
1206 })),
1207 })
1208 .await
1209 .map_err(|_| Error::new("failed to send FocusElementCommand"))?;
1210
1211 loop {
1212 let event =
1213 handle.events.message().await?.ok_or_else(|| {
1214 Error::new("app session closed while waiting for focus result")
1215 })?;
1216
1217 match event.event {
1218 Some(ContextEvent::Attached(_)) => {}
1219 Some(ContextEvent::ElementFocused(_)) => return Ok(()),
1220 Some(ContextEvent::Error(error)) => {
1221 return Err(Error::new(format!(
1222 "app session error while focusing Android locator {:?}: {}",
1223 selector, error.message,
1224 )));
1225 }
1226 Some(ContextEvent::Closed(_)) => {
1227 handle.closed = true;
1228 return Err(Error::new(format!(
1229 "app session {} closed while waiting for focus result",
1230 self.inner.session_id
1231 )));
1232 }
1233 _ => {}
1234 }
1235 }
1236 }
1237
1238 pub async fn press(&self, selector: &str, key: &str, options: PressOptions) -> Result<()> {
1239 let selector = normalize_mobile_selector_for_transport(selector);
1240 let mut state = self.inner.state.lock().await;
1241 let handle = self.ensure_handle(&mut state).await?;
1242 ensure_android_app_open(handle, &self.inner.session_id)?;
1243
1244 handle
1245 .command_tx
1246 .send(ContextSessionCommand {
1247 surface_session_id: self.inner.surface_session_id.clone(),
1248 context_session_id: self.inner.session_id.clone(),
1249 command: Some(ContextCommand::PressKey(PressKeyCommand {
1250 css_selector: selector.clone(),
1251 key: key.to_string(),
1252 text: options.text,
1253 retry_options: command_retry_options(options.timeout_ms),
1254 })),
1255 })
1256 .await
1257 .map_err(|_| Error::new("failed to send PressKeyCommand"))?;
1258
1259 loop {
1260 let event =
1261 handle.events.message().await?.ok_or_else(|| {
1262 Error::new("app session closed while waiting for press result")
1263 })?;
1264
1265 match event.event {
1266 Some(ContextEvent::Attached(_)) => {}
1267 Some(ContextEvent::KeyPressed(_)) => return Ok(()),
1268 Some(ContextEvent::Error(error)) => {
1269 return Err(Error::new(format!(
1270 "app session error while pressing Android key on {:?}: {}",
1271 selector, error.message,
1272 )));
1273 }
1274 Some(ContextEvent::Closed(_)) => {
1275 handle.closed = true;
1276 return Err(Error::new(format!(
1277 "app session {} closed while waiting for press result",
1278 self.inner.session_id
1279 )));
1280 }
1281 _ => {}
1282 }
1283 }
1284 }
1285
1286 pub async fn text_content(
1287 &self,
1288 selector: &str,
1289 options: CommandOptions,
1290 ) -> Result<Option<String>> {
1291 self.read_text(selector, options, true)
1292 .await
1293 .map(|result| Some(result.text))
1294 }
1295
1296 pub async fn inner_text(&self, selector: &str, options: CommandOptions) -> Result<String> {
1297 self.read_text(selector, options, false)
1298 .await
1299 .map(|result| result.text)
1300 }
1301
1302 async fn capture(
1303 &self,
1304 kind: crate::proto::CaptureKind,
1305 selector: &str,
1306 attribute_name: &str,
1307 options: CommandOptions,
1308 ) -> Result<crate::proto::CaptureResolvedEvent> {
1309 let mut state = self.inner.state.lock().await;
1310 let handle = self.ensure_handle(&mut state).await?;
1311 ensure_android_app_open(handle, &self.inner.session_id)?;
1312 handle
1313 .command_tx
1314 .send(ContextSessionCommand {
1315 surface_session_id: self.inner.surface_session_id.clone(),
1316 context_session_id: self.inner.session_id.clone(),
1317 command: Some(ContextCommand::Capture(CaptureCommand {
1318 kind: kind as i32,
1319 css_selector: normalize_mobile_selector_for_transport(selector),
1320 attribute_name: attribute_name.to_string(),
1321 retry_options: command_retry_options(options.timeout_ms),
1322 })),
1323 })
1324 .await
1325 .map_err(|_| Error::new("failed to send native capture command"))?;
1326 loop {
1327 let event = handle
1328 .events
1329 .message()
1330 .await?
1331 .ok_or_else(|| Error::new("native app session closed while capturing"))?;
1332 match event.event {
1333 Some(ContextEvent::CaptureResolved(result)) => return Ok(result),
1334 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1335 Some(ContextEvent::Closed(_)) => {
1336 handle.closed = true;
1337 return Err(Error::new("native app session closed while capturing"));
1338 }
1339 _ => {}
1340 }
1341 }
1342 }
1343
1344 pub async fn input_value(&self, selector: &str, options: CommandOptions) -> Result<String> {
1345 Ok(self
1346 .capture(crate::proto::CaptureKind::InputValue, selector, "", options)
1347 .await?
1348 .value
1349 .unwrap_or_default())
1350 }
1351
1352 pub async fn is_checked(&self, selector: &str, options: CommandOptions) -> Result<bool> {
1353 Ok(self
1354 .capture(crate::proto::CaptureKind::Checked, selector, "", options)
1355 .await?
1356 .checked
1357 .unwrap_or(false))
1358 }
1359
1360 pub async fn get_attribute(
1361 &self,
1362 selector: &str,
1363 name: &str,
1364 options: CommandOptions,
1365 ) -> Result<Option<String>> {
1366 Ok(self
1367 .capture(
1368 crate::proto::CaptureKind::Attribute,
1369 selector,
1370 name,
1371 options,
1372 )
1373 .await?
1374 .value)
1375 }
1376
1377 pub async fn bounding_box(
1378 &self,
1379 selector: &str,
1380 options: CommandOptions,
1381 ) -> Result<Option<BoundingBox>> {
1382 Ok(self
1383 .capture(
1384 crate::proto::CaptureKind::BoundingBox,
1385 selector,
1386 "",
1387 options,
1388 )
1389 .await?
1390 .bounding_box
1391 .map(|value| BoundingBox {
1392 x: value.x,
1393 y: value.y,
1394 width: value.width,
1395 height: value.height,
1396 }))
1397 }
1398
1399 pub async fn wait_for_selector(
1400 &self,
1401 selector: &str,
1402 options: WaitForSelectorOptions,
1403 ) -> Result<()> {
1404 let selector = normalize_mobile_selector_for_transport(selector);
1405 let mut state = self.inner.state.lock().await;
1406 let handle = self.ensure_handle(&mut state).await?;
1407 ensure_android_app_open(handle, &self.inner.session_id)?;
1408
1409 handle
1410 .command_tx
1411 .send(ContextSessionCommand {
1412 surface_session_id: self.inner.surface_session_id.clone(),
1413 context_session_id: self.inner.session_id.clone(),
1414 command: Some(ContextCommand::WaitForSelector(WaitForSelectorCommand {
1415 css_selector: selector.clone(),
1416 visible: options.visible,
1417 retry_options: command_retry_options(options.timeout_ms),
1418 })),
1419 })
1420 .await
1421 .map_err(|_| Error::new("failed to send WaitForSelectorCommand"))?;
1422
1423 loop {
1424 let event = handle.events.message().await?.ok_or_else(|| {
1425 Error::new("app session closed while waiting for selector result")
1426 })?;
1427
1428 match event.event {
1429 Some(ContextEvent::Attached(_)) => {}
1430 Some(ContextEvent::SelectorWaitSatisfied(_)) => return Ok(()),
1431 Some(ContextEvent::Error(error)) => {
1432 return Err(Error::new(format!(
1433 "app session error while waiting for Android locator {:?}: {}",
1434 selector, error.message,
1435 )));
1436 }
1437 Some(ContextEvent::Closed(_)) => {
1438 handle.closed = true;
1439 return Err(Error::new(format!(
1440 "app session {} closed while waiting for selector result",
1441 self.inner.session_id
1442 )));
1443 }
1444 _ => {}
1445 }
1446 }
1447 }
1448
1449 pub async fn screenshot(&self) -> Result<Vec<u8>> {
1450 self.screenshot_with_options(ScreenshotOptions::default())
1451 .await
1452 }
1453
1454 pub async fn screenshot_with_options(&self, options: ScreenshotOptions) -> Result<Vec<u8>> {
1455 let mut state = self.inner.state.lock().await;
1456 let handle = self.ensure_handle(&mut state).await?;
1457 ensure_android_app_open(handle, &self.inner.session_id)?;
1458
1459 handle
1460 .command_tx
1461 .send(ContextSessionCommand {
1462 surface_session_id: self.inner.surface_session_id.clone(),
1463 context_session_id: self.inner.session_id.clone(),
1464 command: Some(ContextCommand::Screenshot(ScreenshotCommand {
1465 retry_options: command_retry_options(options.timeout_ms),
1466 full_page: Some(options.full_page),
1467 })),
1468 })
1469 .await
1470 .map_err(|_| Error::new("failed to send ScreenshotCommand"))?;
1471
1472 loop {
1473 let event = handle.events.message().await?.ok_or_else(|| {
1474 Error::new("app session closed while waiting for screenshot result")
1475 })?;
1476
1477 match event.event {
1478 Some(ContextEvent::Attached(_)) => {}
1479 Some(ContextEvent::ScreenshotCaptured(screenshot)) => {
1480 if let Some(path) = options.path.as_ref() {
1481 std::fs::write(path, &screenshot.png_data).map_err(|error| {
1482 Error::new(format!("write screenshot to {}: {error}", path.display()))
1483 })?;
1484 }
1485 return Ok(screenshot.png_data);
1486 }
1487 Some(ContextEvent::Error(error)) => {
1488 return Err(Error::new(format!(
1489 "app session error while capturing Android screenshot: {}",
1490 error.message
1491 )));
1492 }
1493 Some(ContextEvent::Closed(_)) => {
1494 handle.closed = true;
1495 return Err(Error::new(format!(
1496 "app session {} closed while waiting for screenshot result",
1497 self.inner.session_id
1498 )));
1499 }
1500 _ => {}
1501 }
1502 }
1503 }
1504
1505 async fn ensure_handle<'a>(
1506 &self,
1507 state: &'a mut NativeAppState,
1508 ) -> Result<&'a mut AndroidTabHandle> {
1509 if state.handle.is_none() {
1510 let mut engine = self.inner.runtime.engine.clone();
1511 let (command_tx, command_rx) = mpsc::channel(16);
1512 let response = engine
1513 .context_session(tonic::Request::new(ReceiverStream::new(command_rx)))
1514 .await?;
1515 state.handle = Some(AndroidTabHandle {
1516 command_tx,
1517 events: response.into_inner(),
1518 closed: false,
1519 });
1520 }
1521
1522 state
1523 .handle
1524 .as_mut()
1525 .ok_or_else(|| Error::new("android app session handle was not initialized"))
1526 }
1527
1528 async fn read_text(
1529 &self,
1530 selector: &str,
1531 options: CommandOptions,
1532 text_content: bool,
1533 ) -> Result<TextResult> {
1534 let selector = normalize_mobile_selector_for_transport(selector);
1535 let mut state = self.inner.state.lock().await;
1536 let handle = self.ensure_handle(&mut state).await?;
1537 ensure_android_app_open(handle, &self.inner.session_id)?;
1538
1539 let command = if text_content {
1540 ContextCommand::GetTextContent(GetTextContentCommand {
1541 css_selector: selector.clone(),
1542 retry_options: command_retry_options(options.timeout_ms),
1543 })
1544 } else {
1545 ContextCommand::GetInnerText(GetInnerTextCommand {
1546 css_selector: selector.clone(),
1547 retry_options: command_retry_options(options.timeout_ms),
1548 })
1549 };
1550
1551 handle
1552 .command_tx
1553 .send(ContextSessionCommand {
1554 surface_session_id: self.inner.surface_session_id.clone(),
1555 context_session_id: self.inner.session_id.clone(),
1556 command: Some(command),
1557 })
1558 .await
1559 .map_err(|_| Error::new("failed to send text read command"))?;
1560
1561 loop {
1562 let event =
1563 handle.events.message().await?.ok_or_else(|| {
1564 Error::new("app session closed while waiting for text result")
1565 })?;
1566
1567 match event.event {
1568 Some(ContextEvent::Attached(_)) => {}
1569 Some(ContextEvent::TextContentResolved(text)) => {
1570 return Ok(TextResult {
1571 selector: text.css_selector,
1572 text: text.text,
1573 note: text.note,
1574 });
1575 }
1576 Some(ContextEvent::InnerTextResolved(text)) => {
1577 return Ok(TextResult {
1578 selector: text.css_selector,
1579 text: text.text,
1580 note: text.note,
1581 });
1582 }
1583 Some(ContextEvent::Error(error)) => {
1584 return Err(Error::new(format!(
1585 "app session error while reading Android text for {:?}: {}",
1586 selector, error.message,
1587 )));
1588 }
1589 Some(ContextEvent::Closed(_)) => {
1590 handle.closed = true;
1591 return Err(Error::new(format!(
1592 "app session {} closed while waiting for text result",
1593 self.inner.session_id
1594 )));
1595 }
1596 _ => {}
1597 }
1598 }
1599 }
1600}
1601
1602impl<T: AndroidHookType> AndroidHook<T> {
1603 pub fn id(&self) -> &str {
1604 &self.id
1605 }
1606
1607 pub async fn wait(&self) -> Result<T::Output> {
1608 self.wait_with_options(CommandOptions::default()).await
1609 }
1610
1611 pub async fn wait_with_options(&self, options: CommandOptions) -> Result<T::Output> {
1612 let mut state = self.app.inner.state.lock().await;
1613 let handle = self.app.ensure_handle(&mut state).await?;
1614 handle
1615 .command_tx
1616 .send(ContextSessionCommand {
1617 surface_session_id: self.app.inner.surface_session_id.clone(),
1618 context_session_id: self.app.inner.session_id.clone(),
1619 command: Some(ContextCommand::WaitForHook(WaitForHookCommand {
1620 hook_id: self.id.clone(),
1621 retry_options: command_retry_options(options.timeout_ms),
1622 })),
1623 })
1624 .await
1625 .map_err(|_| Error::new("failed to send Android WaitForHookCommand"))?;
1626 loop {
1627 let event =
1628 handle.events.message().await?.ok_or_else(|| {
1629 Error::new("Android app session closed while waiting for hook")
1630 })?;
1631 match event.event {
1632 Some(ContextEvent::HookCompleted(event)) if event.hook_id == self.id => {
1633 return T::decode(
1634 &self.app,
1635 event
1636 .result
1637 .ok_or_else(|| Error::new("Android hook completed without a result"))?,
1638 );
1639 }
1640 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1641 _ => {}
1642 }
1643 }
1644 }
1645}
1646
1647impl AndroidFileChooser {
1648 pub fn id(&self) -> &str {
1649 &self.id
1650 }
1651 pub fn app(&self) -> &NativeApp {
1652 &self.app
1653 }
1654 pub fn is_multiple(&self) -> bool {
1655 self.is_multiple
1656 }
1657 pub async fn set_file(&self, path: impl AsRef<Path>) -> Result<()> {
1658 self.set_files([path]).await
1659 }
1660 pub async fn set_files<I, P>(&self, paths: I) -> Result<()>
1661 where
1662 I: IntoIterator<Item = P>,
1663 P: AsRef<Path>,
1664 {
1665 let paths = paths
1666 .into_iter()
1667 .map(|path| path.as_ref().to_path_buf())
1668 .collect::<Vec<_>>();
1669 if !self.is_multiple && paths.len() > 1 {
1670 return Err(Error::new(
1671 "Android file chooser does not accept multiple files",
1672 ));
1673 }
1674 let mut state = self.app.inner.state.lock().await;
1675 let handle = self.app.ensure_handle(&mut state).await?;
1676 let mut file_ids = Vec::with_capacity(paths.len());
1677 for path in paths {
1678 file_ids.push(upload_android_client_file(&self.app, handle, &path).await?);
1679 }
1680 handle
1681 .command_tx
1682 .send(ContextSessionCommand {
1683 surface_session_id: self.app.inner.surface_session_id.clone(),
1684 context_session_id: self.app.inner.session_id.clone(),
1685 command: Some(ContextCommand::SetMobileFileChooserFiles(
1686 SetMobileFileChooserFilesCommand {
1687 file_chooser_id: self.id.clone(),
1688 file_ids,
1689 retry_options: None,
1690 },
1691 )),
1692 })
1693 .await
1694 .map_err(|_| Error::new("failed to send SetMobileFileChooserFilesCommand"))?;
1695 loop {
1696 let event = handle.events.message().await?.ok_or_else(|| {
1697 Error::new("Android app session closed while setting chooser files")
1698 })?;
1699 match event.event {
1700 Some(ContextEvent::MobileFileChooserFilesSet(result))
1701 if result.file_chooser_id == self.id =>
1702 {
1703 return Ok(());
1704 }
1705 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1706 _ => {}
1707 }
1708 }
1709 }
1710}
1711
1712impl AndroidDownload {
1713 pub fn id(&self) -> &str {
1714 &self.id
1715 }
1716 pub fn app(&self) -> &NativeApp {
1717 &self.app
1718 }
1719 pub fn suggested_filename(&self) -> &str {
1720 &self.suggested_filename
1721 }
1722 pub async fn save_as(&self, path: impl AsRef<Path>) -> Result<()> {
1723 let mut state = self.app.inner.state.lock().await;
1724 let handle = self.app.ensure_handle(&mut state).await?;
1725 handle
1726 .command_tx
1727 .send(ContextSessionCommand {
1728 surface_session_id: self.app.inner.surface_session_id.clone(),
1729 context_session_id: self.app.inner.session_id.clone(),
1730 command: Some(ContextCommand::SaveMobileDownload(
1731 SaveMobileDownloadCommand {
1732 download_id: self.id.clone(),
1733 retry_options: None,
1734 },
1735 )),
1736 })
1737 .await
1738 .map_err(|_| Error::new("failed to send SaveMobileDownloadCommand"))?;
1739 loop {
1740 let event =
1741 handle.events.message().await?.ok_or_else(|| {
1742 Error::new("Android app session closed while saving download")
1743 })?;
1744 match event.event {
1745 Some(ContextEvent::MobileDownloadSaved(result))
1746 if result.download_id == self.id =>
1747 {
1748 return download_android_client_file(
1749 &self.app,
1750 handle,
1751 &result.file_id,
1752 path.as_ref(),
1753 )
1754 .await;
1755 }
1756 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1757 _ => {}
1758 }
1759 }
1760 }
1761}
1762
1763async fn upload_android_client_file(
1764 app: &NativeApp,
1765 handle: &mut AndroidTabHandle,
1766 path: &Path,
1767) -> Result<String> {
1768 let mut source = fs::File::open(path)
1769 .map_err(|error| Error::new(format!("open upload {}: {error}", path.display())))?;
1770 let size = source
1771 .metadata()
1772 .map_err(|error| Error::new(error.to_string()))?
1773 .len();
1774 let transfer_id = format!(
1775 "rust-mobile-upload-{}-{}",
1776 std::process::id(),
1777 MOBILE_TRANSFER_COUNTER.fetch_add(1, Ordering::Relaxed)
1778 );
1779 let name = path
1780 .file_name()
1781 .and_then(|name| name.to_str())
1782 .ok_or_else(|| Error::new("upload requires a valid file name"))?
1783 .to_string();
1784 let mut offset = 0_u64;
1785 loop {
1786 let mut data = vec![0; 256 * 1024];
1787 let count = source
1788 .read(&mut data)
1789 .map_err(|error| Error::new(error.to_string()))?;
1790 data.truncate(count);
1791 let last = offset + count as u64 >= size;
1792 handle
1793 .command_tx
1794 .send(ContextSessionCommand {
1795 surface_session_id: app.inner.surface_session_id.clone(),
1796 context_session_id: app.inner.session_id.clone(),
1797 command: Some(ContextCommand::UploadFileChunk(UploadFileChunkCommand {
1798 transfer_id: transfer_id.clone(),
1799 name: name.clone(),
1800 offset,
1801 data,
1802 last,
1803 })),
1804 })
1805 .await
1806 .map_err(|_| Error::new("failed to send UploadFileChunkCommand"))?;
1807 offset += count as u64;
1808 if last {
1809 break;
1810 }
1811 }
1812 loop {
1813 let event = handle
1814 .events
1815 .message()
1816 .await?
1817 .ok_or_else(|| Error::new("Android app session closed while uploading file"))?;
1818 match event.event {
1819 Some(ContextEvent::FileUploaded(result)) if result.transfer_id == transfer_id => {
1820 return Ok(result.file_id);
1821 }
1822 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1823 _ => {}
1824 }
1825 }
1826}
1827
1828async fn download_android_client_file(
1829 app: &NativeApp,
1830 handle: &mut AndroidTabHandle,
1831 file_id: &str,
1832 path: &Path,
1833) -> Result<()> {
1834 let temporary = path.with_extension(format!(
1835 "allwright-{}.tmp",
1836 MOBILE_TRANSFER_COUNTER.fetch_add(1, Ordering::Relaxed)
1837 ));
1838 let mut output = fs::OpenOptions::new()
1839 .write(true)
1840 .create_new(true)
1841 .open(&temporary)
1842 .map_err(|error| Error::new(error.to_string()))?;
1843 let mut offset = 0_u64;
1844 loop {
1845 handle
1846 .command_tx
1847 .send(ContextSessionCommand {
1848 surface_session_id: app.inner.surface_session_id.clone(),
1849 context_session_id: app.inner.session_id.clone(),
1850 command: Some(ContextCommand::ReadFileChunk(ReadFileChunkCommand {
1851 file_id: file_id.to_string(),
1852 offset,
1853 max_bytes: 256 * 1024,
1854 })),
1855 })
1856 .await
1857 .map_err(|_| Error::new("failed to send ReadFileChunkCommand"))?;
1858 let event = handle
1859 .events
1860 .message()
1861 .await?
1862 .ok_or_else(|| Error::new("Android app session closed while downloading file"))?;
1863 match event.event {
1864 Some(ContextEvent::FileChunk(chunk))
1865 if chunk.file_id == file_id && chunk.offset == offset =>
1866 {
1867 output
1868 .write_all(&chunk.data)
1869 .map_err(|error| Error::new(error.to_string()))?;
1870 offset += chunk.data.len() as u64;
1871 if chunk.last {
1872 drop(output);
1873 fs::rename(&temporary, path).map_err(|error| Error::new(error.to_string()))?;
1874 return Ok(());
1875 }
1876 }
1877 Some(ContextEvent::Error(error)) => {
1878 let _ = fs::remove_file(&temporary);
1879 return Err(Error::new(error.message));
1880 }
1881 _ => {}
1882 }
1883 }
1884}
1885
1886impl NativeLocator {
1887 pub fn app(&self) -> &NativeApp {
1888 &self.page
1889 }
1890
1891 pub fn selector(&self) -> &str {
1892 &self.selector
1893 }
1894
1895 pub fn locator(&self, selector: impl Into<String>) -> NativeLocator {
1896 NativeLocator {
1897 page: self.page.clone(),
1898 selector: chain_mobile_selector_for_transport(&self.selector, &selector.into()),
1899 }
1900 }
1901
1902 pub fn get_by_role(&self, role: impl Into<String>) -> NativeLocator {
1903 self.get_by_role_with_options(role, RoleOptions::default())
1904 }
1905
1906 pub fn get_by_role_with_options(
1907 &self,
1908 role: impl Into<String>,
1909 options: RoleOptions,
1910 ) -> NativeLocator {
1911 let mut spec = serde_json::to_value(options).expect("role options");
1912 spec["kind"] = serde_json::json!("role");
1913 spec["role"] = serde_json::json!(role.into());
1914 self.locator(mobile_semantic_selector(spec))
1915 }
1916
1917 pub fn get_by_text(&self, text: impl Into<TextMatcher>) -> NativeLocator {
1918 self.get_by_text_with_options(text, TextOptions::default())
1919 }
1920
1921 pub fn get_by_text_with_options(
1922 &self,
1923 text: impl Into<TextMatcher>,
1924 options: TextOptions,
1925 ) -> NativeLocator {
1926 self.locator(mobile_text_selector("text", text.into(), options))
1927 }
1928
1929 pub fn get_by_label(&self, text: impl Into<TextMatcher>) -> NativeLocator {
1930 self.get_by_label_with_options(text, TextOptions::default())
1931 }
1932
1933 pub fn get_by_label_with_options(
1934 &self,
1935 text: impl Into<TextMatcher>,
1936 options: TextOptions,
1937 ) -> NativeLocator {
1938 self.locator(mobile_text_selector("label", text.into(), options))
1939 }
1940
1941 pub fn get_by_test_id(&self, text: impl Into<TextMatcher>) -> NativeLocator {
1942 self.locator(mobile_semantic_selector(
1943 serde_json::json!({"kind":"testId", "text":text.into()}),
1944 ))
1945 }
1946
1947 pub async fn click(&self, options: CommandOptions) -> Result<()> {
1948 self.page.click(&self.selector, options).await
1949 }
1950
1951 pub async fn click_with_options(&self, options: ClickOptions) -> Result<()> {
1952 self.page.click_with_options(&self.selector, options).await
1953 }
1954
1955 pub async fn count(&self, options: CommandOptions) -> Result<u32> {
1956 self.page.count(&self.selector, options).await
1957 }
1958
1959 pub async fn focus(&self, options: CommandOptions) -> Result<()> {
1960 self.page.focus(&self.selector, options).await
1961 }
1962
1963 pub async fn fill(&self, value: &str, options: CommandOptions) -> Result<()> {
1964 self.page.fill(&self.selector, value, options).await
1965 }
1966
1967 pub async fn press(&self, key: &str, options: PressOptions) -> Result<()> {
1968 self.page.press(&self.selector, key, options).await
1969 }
1970
1971 pub async fn text_content(&self, options: CommandOptions) -> Result<Option<String>> {
1972 self.page.text_content(&self.selector, options).await
1973 }
1974
1975 pub async fn inner_text(&self, options: CommandOptions) -> Result<String> {
1976 self.page.inner_text(&self.selector, options).await
1977 }
1978
1979 pub async fn input_value(&self, options: CommandOptions) -> Result<String> {
1980 self.page.input_value(&self.selector, options).await
1981 }
1982
1983 pub async fn is_checked(&self, options: CommandOptions) -> Result<bool> {
1984 self.page.is_checked(&self.selector, options).await
1985 }
1986
1987 pub async fn get_attribute(
1988 &self,
1989 name: &str,
1990 options: CommandOptions,
1991 ) -> Result<Option<String>> {
1992 self.page.get_attribute(&self.selector, name, options).await
1993 }
1994
1995 pub async fn bounding_box(&self, options: CommandOptions) -> Result<Option<BoundingBox>> {
1996 self.page.bounding_box(&self.selector, options).await
1997 }
1998
1999 pub async fn wait_for(&self, options: WaitForSelectorOptions) -> Result<()> {
2000 self.page.wait_for_selector(&self.selector, options).await
2001 }
2002}
2003
2004fn ensure_android_device_open(state: &AndroidDeviceState, session_id: &str) -> Result<()> {
2005 if state.closed {
2006 return Err(Error::new(format!(
2007 "android device session {} is closed",
2008 session_id
2009 )));
2010 }
2011 Ok(())
2012}
2013
2014fn ensure_android_app_open(handle: &AndroidTabHandle, session_id: &str) -> Result<()> {
2015 if handle.closed {
2016 return Err(Error::new(format!(
2017 "android app session {} is closed",
2018 session_id
2019 )));
2020 }
2021 Ok(())
2022}
2023
2024fn mobile_semantic_selector(spec: serde_json::Value) -> String {
2025 format!(
2026 "aw={}",
2027 serde_json::to_string(&spec.to_string()).expect("JSON string")
2028 )
2029}
2030
2031fn mobile_text_selector(kind: &str, text: TextMatcher, options: TextOptions) -> String {
2032 mobile_semantic_selector(serde_json::json!({"kind":kind, "text":text, "exact":options.exact}))
2033}
2034
2035#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2036enum MobileSelectorFlavor {
2037 Css,
2038 XPath,
2039 UiAutomator,
2040 Semantic,
2041}
2042
2043impl MobileSelectorFlavor {
2044 fn as_str(self) -> &'static str {
2045 match self {
2046 Self::Css => "css",
2047 Self::XPath => "xpath",
2048 Self::UiAutomator => "uia",
2049 Self::Semantic => "aw",
2050 }
2051 }
2052}
2053
2054const UIAUTOMATOR_SELECTOR_KEYS: &[&str] = &[
2055 "text",
2056 "textcontains",
2057 "textmatches",
2058 "textstartswith",
2059 "classname",
2060 "classnamematches",
2061 "description",
2062 "desc",
2063 "descriptioncontains",
2064 "desccontains",
2065 "descriptionmatches",
2066 "descmatches",
2067 "descriptionstartswith",
2068 "descstartswith",
2069 "checkable",
2070 "checked",
2071 "clickable",
2072 "longclickable",
2073 "scrollable",
2074 "enabled",
2075 "focusable",
2076 "focused",
2077 "selected",
2078 "packagename",
2079 "package",
2080 "packagenamematches",
2081 "resourceid",
2082 "resourceidmatches",
2083 "index",
2084 "instance",
2085];
2086
2087fn parse_explicit_mobile_selector_prefix(selector: &str) -> Option<(MobileSelectorFlavor, usize)> {
2088 let lowered = selector.to_ascii_lowercase();
2089 if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
2090 return Some((MobileSelectorFlavor::XPath, 6));
2091 }
2092 if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
2093 return Some((MobileSelectorFlavor::UiAutomator, 4));
2094 }
2095 if lowered.starts_with("aw=") || lowered.starts_with("aw:") {
2096 return Some((MobileSelectorFlavor::Semantic, 3));
2097 }
2098 if let Some(prefix_len) = parse_ui_automator_selector_prefix(&lowered) {
2099 return Some((MobileSelectorFlavor::UiAutomator, prefix_len));
2100 }
2101 if lowered.starts_with("text=") || lowered.starts_with("text:") {
2102 return Some((MobileSelectorFlavor::UiAutomator, 5));
2103 }
2104 if lowered.starts_with("id=") || lowered.starts_with("id:") {
2105 return Some((MobileSelectorFlavor::Css, 3));
2106 }
2107 if lowered.starts_with("css=") || lowered.starts_with("css:") {
2108 return Some((MobileSelectorFlavor::Css, 4));
2109 }
2110 None
2111}
2112
2113fn parse_ui_automator_selector_prefix(selector: &str) -> Option<usize> {
2114 UIAUTOMATOR_SELECTOR_KEYS.iter().find_map(|key| {
2115 if selector.starts_with(key) {
2116 let separator = selector.as_bytes().get(key.len()).copied()?;
2117 if separator == b'=' || separator == b':' {
2118 return Some(key.len() + 1);
2119 }
2120 }
2121 None
2122 })
2123}
2124
2125fn find_json_string_end(value: &str) -> Option<usize> {
2126 let bytes = value.as_bytes();
2127 if bytes.first().copied()? != b'"' {
2128 return None;
2129 }
2130
2131 let mut index = 1usize;
2132 let mut escaped = false;
2133 while index < bytes.len() {
2134 let byte = bytes[index];
2135 if escaped {
2136 escaped = false;
2137 index += 1;
2138 continue;
2139 }
2140 match byte {
2141 b'\\' => escaped = true,
2142 b'"' => return Some(index + 1),
2143 _ => {}
2144 }
2145 index += 1;
2146 }
2147 None
2148}
2149
2150fn is_normalized_mobile_transport_selector(selector: &str) -> bool {
2151 let trimmed = selector.trim();
2152 if trimmed.is_empty() {
2153 return false;
2154 }
2155
2156 let mut index = 0usize;
2157 while index < trimmed.len() {
2158 let Some((_, prefix_len)) = parse_explicit_mobile_selector_prefix(&trimmed[index..]) else {
2159 return false;
2160 };
2161 index += prefix_len;
2162
2163 let remainder = &trimmed[index..];
2164 let Some(json_end) = find_json_string_end(remainder) else {
2165 return false;
2166 };
2167 index += json_end;
2168
2169 if index == trimmed.len() {
2170 return true;
2171 }
2172
2173 let whitespace_len = trimmed[index..]
2174 .chars()
2175 .take_while(|char| char.is_ascii_whitespace())
2176 .count();
2177 if whitespace_len == 0 {
2178 return false;
2179 }
2180 index += whitespace_len;
2181
2182 if parse_explicit_mobile_selector_prefix(&trimmed[index..]).is_none() {
2183 return false;
2184 }
2185 }
2186
2187 true
2188}
2189
2190fn decode_selector_body(body: &str) -> String {
2191 let candidate = body.trim();
2192 if candidate.len() >= 2 && candidate.starts_with('"') && candidate.ends_with('"') {
2193 if let Ok(decoded) = serde_json::from_str::<String>(candidate) {
2194 return unescape_shell_escaped_selector(&decoded);
2195 }
2196 }
2197 unescape_shell_escaped_selector(candidate)
2198}
2199
2200fn unescape_shell_escaped_selector(value: &str) -> String {
2201 let mut result = String::with_capacity(value.len());
2202 let mut chars = value.chars().peekable();
2203 while let Some(ch) = chars.next() {
2204 if ch == '\\' {
2205 match chars.peek().copied() {
2206 Some('_' | ' ' | '#' | ':' | '[' | ']' | '(' | ')' | '"' | '\'') => {
2207 result.push(chars.next().expect("peeked char should exist"));
2208 continue;
2209 }
2210 _ => {}
2211 }
2212 }
2213 result.push(ch);
2214 }
2215 result
2216}
2217
2218fn parse_mobile_selector_for_transport(selector: &str) -> (MobileSelectorFlavor, String) {
2219 let trimmed = selector.trim();
2220 if let Some((flavor, prefix_len)) = parse_explicit_mobile_selector_prefix(trimmed) {
2221 let body = decode_selector_body(&trimmed[prefix_len..]);
2222 return match flavor {
2223 MobileSelectorFlavor::Css if prefix_len == 3 => {
2224 let normalized = if body.starts_with('#') {
2225 body
2226 } else {
2227 format!("#{body}")
2228 };
2229 (MobileSelectorFlavor::Css, normalized)
2230 }
2231 MobileSelectorFlavor::UiAutomator
2232 if prefix_len != 4 && !trimmed[..prefix_len].eq_ignore_ascii_case("text=") =>
2233 {
2234 (
2235 MobileSelectorFlavor::UiAutomator,
2236 format!("{}={body}", &trimmed[..prefix_len - 1]),
2237 )
2238 }
2239 MobileSelectorFlavor::UiAutomator if prefix_len == 5 => {
2240 (MobileSelectorFlavor::UiAutomator, format!("text={body}"))
2241 }
2242 _ => (flavor, body),
2243 };
2244 }
2245
2246 if trimmed.starts_with("//")
2247 || trimmed.starts_with(".//")
2248 || trimmed.starts_with("../")
2249 || trimmed.starts_with('/')
2250 || trimmed.starts_with('(')
2251 {
2252 return (MobileSelectorFlavor::XPath, trimmed.to_string());
2253 }
2254
2255 (MobileSelectorFlavor::Css, trimmed.to_string())
2256}
2257
2258fn normalize_mobile_selector_for_transport(selector: &str) -> String {
2259 let trimmed = selector.trim();
2260 if trimmed.is_empty() {
2261 return String::new();
2262 }
2263 let lowered = trimmed.to_ascii_lowercase();
2264 if lowered.starts_with("ref=") || lowered.starts_with("ref:") {
2265 return format!("ref={}", decode_selector_body(&trimmed[4..]));
2266 }
2267 if is_normalized_mobile_transport_selector(trimmed) {
2268 return trimmed.to_string();
2269 }
2270 let (flavor, body) = parse_mobile_selector_for_transport(selector);
2271 format!(
2272 "{}={}",
2273 flavor.as_str(),
2274 serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
2275 )
2276}
2277
2278fn chain_mobile_selector_for_transport(parent: &str, child: &str) -> String {
2279 let parent = if parent.trim().is_empty() {
2280 String::new()
2281 } else {
2282 normalize_mobile_selector_for_transport(parent)
2283 };
2284 let child = if child.trim().is_empty() {
2285 String::new()
2286 } else {
2287 normalize_mobile_selector_for_transport(child)
2288 };
2289 if parent.is_empty() {
2290 return child;
2291 }
2292 if child.is_empty() {
2293 return parent;
2294 }
2295 format!("{parent} {child}")
2296}
2297
2298impl NativeApp {
2299 pub async fn accessibility_snapshot(&self) -> Result<String> {
2300 self.accessibility_snapshot_with_options(AccessibilitySnapshotOptions::default())
2301 .await
2302 }
2303
2304 pub async fn accessibility_snapshot_with_options(
2305 &self,
2306 options: AccessibilitySnapshotOptions,
2307 ) -> Result<String> {
2308 let mut state = self.inner.state.lock().await;
2309 let handle = self.ensure_handle(&mut state).await?;
2310 ensure_android_app_open(handle, &self.inner.session_id)?;
2311 handle
2312 .command_tx
2313 .send(ContextSessionCommand {
2314 surface_session_id: self.inner.surface_session_id.clone(),
2315 context_session_id: self.inner.session_id.clone(),
2316 command: Some(ContextCommand::AccessibilitySnapshot(
2317 AccessibilitySnapshotCommand {
2318 format: match options.format {
2319 AccessibilitySnapshotFormat::Json => "json",
2320 AccessibilitySnapshotFormat::Yaml => "yaml",
2321 }
2322 .into(),
2323 mode: match options.mode {
2324 AccessibilitySnapshotMode::Default => "default",
2325 AccessibilitySnapshotMode::Ai => "ai",
2326 AccessibilitySnapshotMode::Autoexpect => "autoexpect",
2327 AccessibilitySnapshotMode::Codegen => "codegen",
2328 }
2329 .into(),
2330 retry_options: command_retry_options(options.timeout_ms),
2331 },
2332 )),
2333 })
2334 .await
2335 .map_err(|_| Error::new("failed to send AccessibilitySnapshotCommand"))?;
2336 loop {
2337 let event = handle.events.message().await?.ok_or_else(|| {
2338 Error::new("android app session closed while capturing accessibility snapshot")
2339 })?;
2340 match event.event {
2341 Some(ContextEvent::AccessibilitySnapshotCaptured(result)) => {
2342 return Ok(result.snapshot);
2343 }
2344 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
2345 Some(ContextEvent::Closed(_)) => {
2346 handle.closed = true;
2347 return Err(Error::new(
2348 "android app session closed while capturing accessibility snapshot",
2349 ));
2350 }
2351 _ => {}
2352 }
2353 }
2354 }
2355}