1use tauri::{Manager, Runtime};
2use victauri_core::WindowState;
3
4pub trait WebviewBridge: Send + Sync {
14 fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String>;
20 fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState>;
22 fn try_get_window_states(&self, label: Option<&str>) -> Result<Vec<WindowState>, String> {
30 Ok(self.get_window_states(label))
31 }
32 fn list_window_labels(&self) -> Vec<String>;
34 fn try_list_window_labels(&self) -> Result<Vec<String>, String> {
43 Ok(self.list_window_labels())
44 }
45 fn eval_webview_resolved(&self, label: Option<&str>, script: &str) -> Result<String, String> {
54 self.eval_webview(label, script)?;
55 Ok(label.map(str::to_string).unwrap_or_default())
56 }
57 fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String>;
64 fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String>;
70 fn resize_window(&self, label: Option<&str>, width: u32, height: u32) -> Result<(), String>;
76 fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String>;
82 fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String>;
88
89 fn native_type_text(&self, _label: Option<&str>, _text: &str) -> Result<(), String> {
103 Err(
104 "native (trusted) keyboard input is not implemented on this platform; \
105 use synthetic input via the `input` tool without `trusted`"
106 .to_string(),
107 )
108 }
109
110 fn native_key(&self, _label: Option<&str>, _key: &str) -> Result<(), String> {
116 Err(
117 "native (trusted) key input is not implemented on this platform; \
118 use synthetic input via the `input` tool without `trusted`"
119 .to_string(),
120 )
121 }
122
123 fn native_click(&self, _label: Option<&str>, _x: f64, _y: f64) -> Result<(), String> {
129 Err(
130 "native (trusted) mouse input is not implemented on this platform; \
131 use synthetic input via the `interact` tool"
132 .to_string(),
133 )
134 }
135
136 fn app_data_dir(&self) -> Result<std::path::PathBuf, String> {
144 Err("backend access not available".to_string())
145 }
146
147 fn app_config_dir(&self) -> Result<std::path::PathBuf, String> {
153 Err("backend access not available".to_string())
154 }
155
156 fn app_log_dir(&self) -> Result<std::path::PathBuf, String> {
162 Err("backend access not available".to_string())
163 }
164
165 fn app_local_data_dir(&self) -> Result<std::path::PathBuf, String> {
171 Err("backend access not available".to_string())
172 }
173
174 #[must_use]
176 fn tauri_config(&self) -> serde_json::Value {
177 serde_json::Value::Null
178 }
179}
180
181fn find_window<'a, R: Runtime>(
182 windows: &'a std::collections::HashMap<String, tauri::WebviewWindow<R>>,
183 label: Option<&str>,
184) -> Result<&'a tauri::WebviewWindow<R>, String> {
185 match label {
186 Some(l) => windows
187 .get(l)
188 .ok_or_else(|| format!("window not found: {l}")),
189 None => windows
190 .get("main")
191 .or_else(|| {
195 let mut visible: Vec<_> = windows
196 .iter()
197 .filter(|(_, w)| w.is_visible().unwrap_or(false))
198 .collect();
199 visible.sort_by(|a, b| a.0.cmp(b.0));
200 visible.first().map(|(_, w)| *w)
201 })
202 .or_else(|| windows.iter().min_by(|a, b| a.0.cmp(b.0)).map(|(_, w)| w))
203 .ok_or_else(|| "no window available".to_string()),
204 }
205}
206
207pub(crate) fn default_window_label<R: Runtime>(app: &tauri::AppHandle<R>) -> Option<String> {
211 let windows = app.webview_windows();
212 find_window(&windows, None)
213 .ok()
214 .map(|window| window.label().to_string())
215}
216
217static MAIN_DISPATCH_LOCK: DispatchGate = DispatchGate::new();
249
250struct DispatchGate {
259 lock: std::sync::Mutex<()>,
260 in_flight: std::sync::atomic::AtomicUsize,
261}
262
263impl DispatchGate {
264 const fn new() -> Self {
265 Self {
266 lock: std::sync::Mutex::new(()),
267 in_flight: std::sync::atomic::AtomicUsize::new(0),
268 }
269 }
270}
271
272struct InFlightGuard(&'static DispatchGate);
275
276impl Drop for InFlightGuard {
277 fn drop(&mut self) {
278 self.0
279 .in_flight
280 .fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
281 }
282}
283
284static MAIN_THREAD: std::sync::OnceLock<std::thread::ThreadId> = std::sync::OnceLock::new();
286
287fn record_main_thread() {
290 let _ = MAIN_THREAD.set(std::thread::current().id());
291}
292
293thread_local! {
294 static MAIN_APP: std::cell::RefCell<Option<Box<dyn std::any::Any>>> =
299 const { std::cell::RefCell::new(None) };
300}
301
302fn install_main_app<R: Runtime>(app: &tauri::AppHandle<R>) {
306 MAIN_APP.with(|slot| *slot.borrow_mut() = Some(Box::new(app.clone())));
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub(crate) enum MainThreadAdoption {
312 Inline,
314 Posted,
316}
317
318pub(crate) fn adopt_main_thread<R: Runtime>(
335 app: &tauri::AppHandle<R>,
336) -> Result<MainThreadAdoption, String> {
337 adopt_on_main(
338 app,
339 |job| app.run_on_main_thread(job).map_err(|e| e.to_string()),
340 |main: &tauri::AppHandle<R>| {
341 record_main_thread();
342 install_main_app(main);
343 },
344 )
345}
346
347fn adopt_on_main<H: Clone + Send + 'static>(
351 handle: &H,
352 post: impl Fn(Box<dyn FnOnce() + Send>) -> Result<(), String>,
353 adopt: impl Fn(&H) + Send + 'static,
354) -> Result<MainThreadAdoption, String> {
355 use std::sync::atomic::{AtomicBool, Ordering};
356
357 let here = std::thread::current().id();
358 let ran_here = std::sync::Arc::new(AtomicBool::new(false));
359 let flag = std::sync::Arc::clone(&ran_here);
360 post(Box::new(move || {
363 if std::thread::current().id() == here {
364 flag.store(true, Ordering::Release);
365 }
366 }))?;
367 if ran_here.load(Ordering::Acquire) {
368 adopt(handle);
369 return Ok(MainThreadAdoption::Inline);
370 }
371 let owned = handle.clone();
372 post(Box::new(move || adopt(&owned)))?;
373 Ok(MainThreadAdoption::Posted)
374}
375
376fn main_app<R: Runtime>() -> Option<tauri::AppHandle<R>> {
378 MAIN_APP.with(|slot| {
379 slot.borrow()
380 .as_ref()
381 .and_then(|any| any.downcast_ref::<tauri::AppHandle<R>>())
382 .cloned()
383 })
384}
385
386fn is_main_thread() -> bool {
388 MAIN_THREAD
389 .get()
390 .is_some_and(|id| *id == std::thread::current().id())
391}
392
393fn on_main<R, T, F>(app: &tauri::AppHandle<R>, what: &str, f: F) -> Result<T, String>
443where
444 R: Runtime,
445 T: Send + 'static,
446 F: FnOnce(&tauri::AppHandle<R>) -> T + Send + 'static,
447{
448 let timeout = std::time::Duration::from_secs(10);
449
450 if is_main_thread() {
457 return std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(app)))
458 .map_err(|_| format!("{what} panicked on the main thread"));
459 }
460
461 let round_trip = move || -> Result<T, String> {
462 let owned_what = what.to_string();
465 serialized_round_trip(
466 &MAIN_DISPATCH_LOCK,
467 what,
468 timeout,
469 |job| {
470 app.run_on_main_thread(job)
471 .map_err(|e| format!("failed to dispatch {what} to the main thread: {e}"))
472 },
473 move || {
474 main_app::<R>().map(|main| f(&main)).ok_or_else(|| {
475 format!(
476 "{owned_what}: the main-thread app handle is not installed (the plugin \
477 setup has not run)"
478 )
479 })
480 },
481 )?
482 };
483
484 match tokio::runtime::Handle::try_current().map(|h| h.runtime_flavor()) {
489 Ok(tokio::runtime::RuntimeFlavor::MultiThread) => tokio::task::block_in_place(round_trip),
490 _ => round_trip(),
491 }
492}
493
494fn serialized_round_trip<T, F>(
507 gate: &'static DispatchGate,
508 what: &str,
509 timeout: std::time::Duration,
510 post: impl FnOnce(Box<dyn FnOnce() + Send>) -> Result<(), String>,
511 f: F,
512) -> Result<T, String>
513where
514 T: Send + 'static,
515 F: FnOnce() -> T + Send + 'static,
516{
517 let deadline = std::time::Instant::now() + timeout;
518 let lock_timeout = || {
519 format!(
520 "{what} did not complete on the main thread: timed out waiting for the main-thread \
521 dispatch lock"
522 )
523 };
524 let _serialize = loop {
525 match gate.lock.try_lock() {
526 Ok(guard) => break guard,
527 Err(std::sync::TryLockError::Poisoned(poisoned)) => break poisoned.into_inner(),
528 Err(std::sync::TryLockError::WouldBlock) => {
529 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
530 if remaining.is_zero() {
531 return Err(lock_timeout());
532 }
533 std::thread::sleep(remaining.min(std::time::Duration::from_millis(2)));
534 }
535 }
536 };
537 while gate.in_flight.load(std::sync::atomic::Ordering::Acquire) != 0 {
545 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
546 if remaining.is_zero() {
547 return Err(format!(
548 "{what} did not complete on the main thread: timed out waiting for an earlier \
549 main-thread call that is still running (it will not run)"
550 ));
551 }
552 std::thread::sleep(remaining.min(std::time::Duration::from_millis(2)));
553 }
554 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
555 if remaining.is_zero() {
556 return Err(lock_timeout());
557 }
558 let counted_post = move |job: Box<dyn FnOnce() + Send>| {
562 post(Box::new(move || {
563 gate.in_flight
564 .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
565 let _done = InFlightGuard(gate);
566 job();
567 }))
568 };
569 dispatch_and_wait(what, remaining, timeout, counted_post, f)
572}
573
574const JOB_QUEUED: u8 = 0;
576const JOB_RUNNING: u8 = 1;
578const JOB_ABANDONED: u8 = 2;
580
581fn dispatch_and_wait<T, F>(
591 what: &str,
592 remaining: std::time::Duration,
593 grace: std::time::Duration,
594 post: impl FnOnce(Box<dyn FnOnce() + Send>) -> Result<(), String>,
595 f: F,
596) -> Result<T, String>
597where
598 T: Send + 'static,
599 F: FnOnce() -> T + Send + 'static,
600{
601 use std::sync::atomic::{AtomicU8, Ordering};
602 use std::sync::mpsc::RecvTimeoutError;
603
604 let (tx, rx) = std::sync::mpsc::channel();
605 let state = std::sync::Arc::new(AtomicU8::new(JOB_QUEUED));
606 let job_state = std::sync::Arc::clone(&state);
607 post(Box::new(move || {
608 if job_state
609 .compare_exchange(JOB_QUEUED, JOB_RUNNING, Ordering::AcqRel, Ordering::Acquire)
610 .is_err()
611 {
612 return;
613 }
614 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
615 let _ = tx.send(result);
617 }))?;
618
619 let outcome = match rx.recv_timeout(remaining) {
620 Ok(outcome) => outcome,
621 Err(RecvTimeoutError::Disconnected) => {
622 return Err(format!(
623 "{what} did not complete on the main thread: the event loop dropped it"
624 ));
625 }
626 Err(RecvTimeoutError::Timeout) => {
627 if state
628 .compare_exchange(
629 JOB_QUEUED,
630 JOB_ABANDONED,
631 Ordering::AcqRel,
632 Ordering::Acquire,
633 )
634 .is_ok()
635 {
636 return Err(format!(
637 "{what} did not complete on the main thread: timed out before it started \
638 (it will not run)"
639 ));
640 }
641 rx.recv_timeout(grace).map_err(|_| {
643 format!(
644 "{what} started on the main thread but did not finish in time; its outcome \
645 is unknown"
646 )
647 })?
648 }
649 };
650 outcome.map_err(|_panic| format!("{what} panicked on the main thread"))
651}
652
653impl<R: Runtime> WebviewBridge for tauri::AppHandle<R> {
654 fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
655 let label = label.map(str::to_string);
656 let script = script.to_string();
657 on_main(self, "eval_webview", move |app| {
658 let windows = app.webview_windows();
659 let webview = find_window(&windows, label.as_deref())?;
660 webview.eval(&script).map_err(|e| e.to_string())
661 })?
662 }
663
664 fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
665 self.try_get_window_states(label).unwrap_or_else(|e| {
668 tracing::warn!("get_window_states: {e}");
669 Vec::new()
670 })
671 }
672
673 fn try_get_window_states(&self, label: Option<&str>) -> Result<Vec<WindowState>, String> {
674 let label = label.map(str::to_string);
675 on_main(self, "get_window_states", move |app| {
676 let windows = app.webview_windows();
677 let mut states = Vec::new();
678
679 for (win_label, window) in &windows {
680 if let Some(filter) = label.as_deref()
681 && win_label != filter
682 {
683 continue;
684 }
685
686 let pos = window.outer_position().unwrap_or_default();
687 let size = window.inner_size().unwrap_or_default();
688
689 states.push(
690 WindowState::new(win_label.clone())
691 .with_title(window.title().unwrap_or_default())
692 .with_url(window.url().map(|u| u.to_string()).unwrap_or_default())
693 .with_visible(window.is_visible().unwrap_or(false))
694 .with_focused(window.is_focused().unwrap_or(false))
695 .with_maximized(window.is_maximized().unwrap_or(false))
696 .with_minimized(window.is_minimized().unwrap_or(false))
697 .with_fullscreen(window.is_fullscreen().unwrap_or(false))
698 .with_position(pos.x, pos.y)
699 .with_size(size.width, size.height),
700 );
701 }
702
703 states
704 })
705 }
706
707 fn list_window_labels(&self) -> Vec<String> {
708 self.try_list_window_labels().unwrap_or_else(|e| {
709 tracing::warn!("list_window_labels: {e}");
710 Vec::new()
711 })
712 }
713
714 fn try_list_window_labels(&self) -> Result<Vec<String>, String> {
715 on_main(self, "list_window_labels", |app| {
716 app.webview_windows().keys().cloned().collect()
717 })
718 }
719
720 fn eval_webview_resolved(&self, label: Option<&str>, script: &str) -> Result<String, String> {
721 let label = label.map(str::to_string);
722 let script = script.to_string();
723 on_main(self, "eval_webview", move |app| {
724 let windows = app.webview_windows();
725 let webview = find_window(&windows, label.as_deref())?;
726 webview.eval(&script).map_err(|e| e.to_string())?;
727 Ok(webview.label().to_string())
728 })?
729 }
730
731 fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
732 let label = label.map(str::to_string);
733 on_main(self, "get_native_handle", move |app| {
734 use raw_window_handle::{HasWindowHandle, RawWindowHandle};
735
736 let windows = app.webview_windows();
737 let _webview = find_window(&windows, label.as_deref())?;
738 let handle = _webview.window_handle().map_err(|e| e.to_string())?;
739 match handle.as_raw() {
740 #[cfg(windows)]
741 RawWindowHandle::Win32(h) => Ok(h.hwnd.get()),
742 #[cfg(target_os = "macos")]
743 RawWindowHandle::AppKit(h) => {
744 macos_window_number(h.ns_view.as_ptr())
747 }
748 #[cfg(target_os = "linux")]
749 RawWindowHandle::Xlib(h) => Ok(h.window as isize),
750 #[cfg(target_os = "linux")]
751 RawWindowHandle::Xcb(h) => Ok(h.window.get() as isize),
752 _ => Err("unsupported window handle type on this platform".to_string()),
753 }
754 })?
755 }
756
757 #[cfg(windows)]
758 fn native_type_text(&self, label: Option<&str>, text: &str) -> Result<(), String> {
759 let hwnd = self.get_native_handle(label)?;
760 win_focus(hwnd)?;
761 win_send_text(text)
762 }
763
764 #[cfg(windows)]
765 fn native_key(&self, label: Option<&str>, key: &str) -> Result<(), String> {
766 let hwnd = self.get_native_handle(label)?;
767 win_focus(hwnd)?;
768 win_send_key(key)
769 }
770
771 #[cfg(windows)]
772 fn native_click(&self, label: Option<&str>, x: f64, y: f64) -> Result<(), String> {
773 let hwnd = self.get_native_handle(label)?;
774 win_focus(hwnd)?;
775 win_click(hwnd, x, y)
776 }
777
778 fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
779 let label = label.map(str::to_string);
780 let action = action.to_string();
781 on_main(self, "manage_window", move |app| {
782 let windows = app.webview_windows();
783 let window = find_window(&windows, label.as_deref())?;
784
785 match action.as_str() {
786 "minimize" => window.minimize().map_err(|e| e.to_string())?,
787 "unminimize" => window.unminimize().map_err(|e| e.to_string())?,
788 "maximize" => window.maximize().map_err(|e| e.to_string())?,
789 "unmaximize" => window.unmaximize().map_err(|e| e.to_string())?,
790 "close" => window.close().map_err(|e| e.to_string())?,
791 "focus" => window.set_focus().map_err(|e| e.to_string())?,
792 "show" => window.show().map_err(|e| e.to_string())?,
793 "hide" => window.hide().map_err(|e| e.to_string())?,
794 "fullscreen" => window.set_fullscreen(true).map_err(|e| e.to_string())?,
795 "unfullscreen" => window.set_fullscreen(false).map_err(|e| e.to_string())?,
796 "always_on_top" => window.set_always_on_top(true).map_err(|e| e.to_string())?,
797 "not_always_on_top" => {
798 window.set_always_on_top(false).map_err(|e| e.to_string())?;
799 }
800 _ => return Err(format!("unknown action: {action}")),
801 }
802
803 Ok(format!("{action} executed"))
804 })?
805 }
806
807 fn resize_window(&self, label: Option<&str>, width: u32, height: u32) -> Result<(), String> {
808 let label = label.map(str::to_string);
809 on_main(self, "resize_window", move |app| {
810 let windows = app.webview_windows();
811 let window = find_window(&windows, label.as_deref())?;
812
813 window
814 .set_size(tauri::LogicalSize::new(width, height))
815 .map_err(|e| e.to_string())
816 })?
817 }
818
819 fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
820 let label = label.map(str::to_string);
821 on_main(self, "move_window", move |app| {
822 let windows = app.webview_windows();
823 let window = find_window(&windows, label.as_deref())?;
824
825 window
826 .set_position(tauri::LogicalPosition::new(x, y))
827 .map_err(|e| e.to_string())
828 })?
829 }
830
831 fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
832 let label = label.map(str::to_string);
833 let title = title.to_string();
834 on_main(self, "set_window_title", move |app| {
835 let windows = app.webview_windows();
836 let window = find_window(&windows, label.as_deref())?;
837
838 window.set_title(&title).map_err(|e| e.to_string())
839 })?
840 }
841
842 fn app_data_dir(&self) -> Result<std::path::PathBuf, String> {
843 self.path().app_data_dir().map_err(|e| e.to_string())
844 }
845
846 fn app_config_dir(&self) -> Result<std::path::PathBuf, String> {
847 self.path().app_config_dir().map_err(|e| e.to_string())
848 }
849
850 fn app_log_dir(&self) -> Result<std::path::PathBuf, String> {
851 self.path().app_log_dir().map_err(|e| e.to_string())
852 }
853
854 fn app_local_data_dir(&self) -> Result<std::path::PathBuf, String> {
855 self.path().app_local_data_dir().map_err(|e| e.to_string())
856 }
857
858 fn tauri_config(&self) -> serde_json::Value {
859 let config = self.config();
860
861 let windows: Vec<serde_json::Value> = config
862 .app
863 .windows
864 .iter()
865 .map(|w| {
866 serde_json::json!({
867 "label": w.label,
868 "title": w.title,
869 "url": format!("{}", w.url),
870 "width": w.width,
871 "height": w.height,
872 "visible": w.visible,
873 "resizable": w.resizable,
874 "fullscreen": w.fullscreen,
875 "decorations": w.decorations,
876 "transparent": w.transparent,
877 "always_on_top": w.always_on_top,
878 })
879 })
880 .collect();
881
882 let plugins: Vec<String> = config.plugins.0.keys().cloned().collect();
883
884 let security = serde_json::json!({
885 "csp": config.app.security.csp.as_ref().map(|c| format!("{c}")),
886 "freeze_prototype": config.app.security.freeze_prototype,
887 "capabilities": config.app.security.capabilities.iter().map(|c| {
888 match c {
889 tauri::utils::config::CapabilityEntry::Inlined(cap) => {
890 serde_json::json!({
891 "identifier": cap.identifier,
892 "description": cap.description,
893 "windows": cap.windows,
894 "webviews": cap.webviews,
895 "permissions": cap.permissions.iter().map(|p| format!("{p:?}")).collect::<Vec<_>>(),
896 "platforms": cap.platforms,
897 })
898 }
899 tauri::utils::config::CapabilityEntry::Reference(path) => {
900 serde_json::json!({ "reference": path })
901 }
902 }
903 }).collect::<Vec<_>>(),
904 });
905
906 serde_json::json!({
907 "identifier": config.identifier,
908 "product_name": config.product_name,
909 "version": config.version,
910 "windows": windows,
911 "plugins": plugins,
912 "security": security,
913 })
914 }
915}
916
917#[cfg(target_os = "macos")]
918#[allow(unsafe_code)]
919fn macos_window_number(ns_view: *mut std::ffi::c_void) -> Result<isize, String> {
920 unsafe extern "C" {
921 fn objc_msgSend(obj: *mut std::ffi::c_void, sel: *mut std::ffi::c_void) -> isize;
922 fn sel_registerName(name: *const std::ffi::c_char) -> *mut std::ffi::c_void;
923 }
924
925 if ns_view.is_null() {
926 return Err("null NSView handle".to_string());
927 }
928
929 unsafe {
933 let sel_window = sel_registerName(c"window".as_ptr());
934 let ns_window = objc_msgSend(ns_view, sel_window);
935 if ns_window == 0 {
936 return Err("NSView has no parent NSWindow".to_string());
937 }
938 let sel_window_number = sel_registerName(c"windowNumber".as_ptr());
939 let ns_window_ptr = ns_window as *mut std::ffi::c_void;
940 let window_number = objc_msgSend(ns_window_ptr, sel_window_number);
941 if window_number <= 0 {
942 return Err(format!("invalid CGWindowID: {window_number}"));
943 }
944 Ok(window_number)
945 }
946}
947
948#[cfg(windows)]
954fn win_hwnd(hwnd: isize) -> windows::Win32::Foundation::HWND {
955 windows::Win32::Foundation::HWND(hwnd as *mut core::ffi::c_void)
956}
957
958#[cfg(any(windows, test))]
961fn foreground_verdict(target_root: isize, foreground_root: isize) -> Result<(), String> {
962 if target_root != 0 && target_root == foreground_root {
963 Ok(())
964 } else {
965 Err(
966 "refusing trusted input: the app window could not be brought to the foreground \
967 (Windows' foreground lock keeps focus with the app the user is working in, e.g. a \
968 terminal), so the keystrokes or click would go to that app instead. Bring the app \
969 window to the front and retry, or omit `trusted` to use synthetic input."
970 .to_string(),
971 )
972 }
973}
974
975#[cfg(any(windows, test))]
977fn click_target_verdict(target_root: isize, point_root: isize) -> Result<(), String> {
978 if target_root != 0 && target_root == point_root {
979 Ok(())
980 } else {
981 Err(
982 "refusing trusted click: the point is covered by another window (or lies outside \
983 this one), so the click would land there. Make the element visible on screen and \
984 retry, or omit `trusted` to use a synthetic click."
985 .to_string(),
986 )
987 }
988}
989
990#[cfg(any(windows, test))]
996fn client_point_verdict(x: f64, y: f64, client_w: i32, client_h: i32) -> Result<(), String> {
997 let inside = x.is_finite()
998 && y.is_finite()
999 && x >= 0.0
1000 && y >= 0.0
1001 && x < f64::from(client_w)
1002 && y < f64::from(client_h);
1003 if inside {
1004 Ok(())
1005 } else {
1006 Err(format!(
1007 "refusing trusted click: ({x}, {y}) lies outside the window's content area \
1008 ({client_w}x{client_h} physical px), so it would land on the title bar, a caption \
1009 button or the border. Scroll the element into view and retry, or omit `trusted` to \
1010 use a synthetic click."
1011 ))
1012 }
1013}
1014
1015#[allow(unsafe_code)]
1017#[cfg(windows)]
1018fn win_root(hwnd: windows::Win32::Foundation::HWND) -> isize {
1019 use windows::Win32::UI::WindowsAndMessaging::{GA_ROOT, GetAncestor};
1020 unsafe { GetAncestor(hwnd, GA_ROOT) }.0 as isize
1022}
1023
1024#[allow(unsafe_code)]
1029#[cfg(windows)]
1030fn win_focus(hwnd: isize) -> Result<(), String> {
1031 use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, SetForegroundWindow};
1032 let target = win_hwnd(hwnd);
1033 let target_root = win_root(target);
1034 unsafe {
1037 let _ = SetForegroundWindow(target);
1038 }
1039 std::thread::sleep(std::time::Duration::from_millis(40));
1041 let mut verdict = Ok(());
1042 for _ in 0..8 {
1043 verdict = foreground_verdict(target_root, win_root(unsafe { GetForegroundWindow() }));
1045 if verdict.is_ok() {
1046 break;
1047 }
1048 std::thread::sleep(std::time::Duration::from_millis(20));
1049 }
1050 verdict
1051}
1052
1053#[cfg(windows)]
1054fn win_keyboard_input(
1055 vk: u16,
1056 scan: u16,
1057 key_up: bool,
1058 unicode: bool,
1059) -> windows::Win32::UI::Input::KeyboardAndMouse::INPUT {
1060 use windows::Win32::UI::Input::KeyboardAndMouse::{
1061 INPUT, INPUT_0, INPUT_KEYBOARD, KEYBD_EVENT_FLAGS, KEYBDINPUT, KEYEVENTF_KEYUP,
1062 KEYEVENTF_UNICODE, VIRTUAL_KEY,
1063 };
1064 let mut flags = KEYBD_EVENT_FLAGS(0);
1065 if unicode {
1066 flags |= KEYEVENTF_UNICODE;
1067 }
1068 if key_up {
1069 flags |= KEYEVENTF_KEYUP;
1070 }
1071 INPUT {
1072 r#type: INPUT_KEYBOARD,
1073 Anonymous: INPUT_0 {
1074 ki: KEYBDINPUT {
1075 wVk: VIRTUAL_KEY(vk),
1076 wScan: scan,
1077 dwFlags: flags,
1078 time: 0,
1079 dwExtraInfo: 0,
1080 },
1081 },
1082 }
1083}
1084
1085#[allow(unsafe_code)]
1087#[cfg(windows)]
1088fn win_send_text(text: &str) -> Result<(), String> {
1089 use windows::Win32::UI::Input::KeyboardAndMouse::{INPUT, SendInput};
1090 let mut inputs: Vec<INPUT> = Vec::new();
1091 for unit in text.encode_utf16() {
1092 inputs.push(win_keyboard_input(0, unit, false, true));
1093 inputs.push(win_keyboard_input(0, unit, true, true));
1094 }
1095 if inputs.is_empty() {
1096 return Ok(());
1097 }
1098 let cb = i32::try_from(std::mem::size_of::<INPUT>()).unwrap_or(0);
1099 let sent = unsafe { SendInput(&inputs, cb) } as usize;
1101 if sent == inputs.len() {
1102 Ok(())
1103 } else {
1104 Err(format!(
1105 "SendInput delivered {sent}/{} key events",
1106 inputs.len()
1107 ))
1108 }
1109}
1110
1111#[cfg(windows)]
1113fn win_vk_for_key(key: &str) -> Option<u16> {
1114 use windows::Win32::UI::Input::KeyboardAndMouse as k;
1115 let vk = match key {
1116 "Enter" | "Return" => k::VK_RETURN,
1117 "Tab" => k::VK_TAB,
1118 "Escape" | "Esc" => k::VK_ESCAPE,
1119 "Backspace" => k::VK_BACK,
1120 "Delete" | "Del" => k::VK_DELETE,
1121 "ArrowUp" | "Up" => k::VK_UP,
1122 "ArrowDown" | "Down" => k::VK_DOWN,
1123 "ArrowLeft" | "Left" => k::VK_LEFT,
1124 "ArrowRight" | "Right" => k::VK_RIGHT,
1125 "Home" => k::VK_HOME,
1126 "End" => k::VK_END,
1127 "PageUp" => k::VK_PRIOR,
1128 "PageDown" => k::VK_NEXT,
1129 "Space" | " " => k::VK_SPACE,
1130 "F1" => k::VK_F1,
1131 "F2" => k::VK_F2,
1132 "F3" => k::VK_F3,
1133 "F4" => k::VK_F4,
1134 "F5" => k::VK_F5,
1135 "F6" => k::VK_F6,
1136 "F7" => k::VK_F7,
1137 "F8" => k::VK_F8,
1138 "F9" => k::VK_F9,
1139 "F10" => k::VK_F10,
1140 "F11" => k::VK_F11,
1141 "F12" => k::VK_F12,
1142 _ => return None,
1143 };
1144 Some(vk.0)
1145}
1146
1147#[allow(unsafe_code)]
1149#[cfg(windows)]
1150fn win_send_key(key: &str) -> Result<(), String> {
1151 use windows::Win32::UI::Input::KeyboardAndMouse::{INPUT, SendInput};
1152 let inputs: Vec<INPUT> = if let Some(vk) = win_vk_for_key(key) {
1153 vec![
1154 win_keyboard_input(vk, 0, false, false),
1155 win_keyboard_input(vk, 0, true, false),
1156 ]
1157 } else {
1158 let mut chars = key.chars();
1160 let (Some(c), None) = (chars.next(), chars.next()) else {
1161 return Err(format!(
1162 "unknown key '{key}' (use a named key or a single character)"
1163 ));
1164 };
1165 let mut buf = [0u16; 2];
1166 let mut v = Vec::new();
1167 for unit in c.encode_utf16(&mut buf) {
1168 v.push(win_keyboard_input(0, *unit, false, true));
1169 v.push(win_keyboard_input(0, *unit, true, true));
1170 }
1171 v
1172 };
1173 let cb = i32::try_from(std::mem::size_of::<INPUT>()).unwrap_or(0);
1174 let sent = unsafe { SendInput(&inputs, cb) } as usize;
1176 if sent == inputs.len() {
1177 Ok(())
1178 } else {
1179 Err(format!(
1180 "SendInput delivered {sent}/{} key events",
1181 inputs.len()
1182 ))
1183 }
1184}
1185
1186#[allow(unsafe_code)]
1189#[cfg(windows)]
1190fn win_click(hwnd: isize, x: f64, y: f64) -> Result<(), String> {
1191 use windows::Win32::Foundation::POINT;
1192 use windows::Win32::Foundation::RECT;
1193 use windows::Win32::Graphics::Gdi::ClientToScreen;
1194 use windows::Win32::UI::HiDpi::GetDpiForWindow;
1195 use windows::Win32::UI::Input::KeyboardAndMouse::{
1196 INPUT, INPUT_0, INPUT_MOUSE, MOUSE_EVENT_FLAGS, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_LEFTDOWN,
1197 MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_VIRTUALDESK, MOUSEINPUT, SendInput,
1198 };
1199 use windows::Win32::UI::WindowsAndMessaging::{
1200 GetClientRect, GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
1201 SM_YVIRTUALSCREEN, WindowFromPoint,
1202 };
1203 let h = win_hwnd(hwnd);
1204 let (nx, ny) = unsafe {
1207 let dpi = GetDpiForWindow(h);
1208 let scale = if dpi == 0 { 1.0 } else { f64::from(dpi) / 96.0 };
1209 let (px, py) = (x * scale, y * scale);
1210 let mut client = RECT::default();
1212 GetClientRect(h, &mut client)
1213 .map_err(|e| format!("refusing trusted click: cannot read the client area: {e}"))?;
1214 client_point_verdict(px, py, client.right, client.bottom)?;
1215 let mut pt = POINT {
1216 x: px as i32,
1217 y: py as i32,
1218 };
1219 let _ = ClientToScreen(h, &mut pt);
1220 click_target_verdict(win_root(h), win_root(WindowFromPoint(pt)))?;
1222 let vx = GetSystemMetrics(SM_XVIRTUALSCREEN);
1223 let vy = GetSystemMetrics(SM_YVIRTUALSCREEN);
1224 let vw = GetSystemMetrics(SM_CXVIRTUALSCREEN);
1225 let vh = GetSystemMetrics(SM_CYVIRTUALSCREEN);
1226 if vw <= 1 || vh <= 1 {
1227 return Err("virtual screen metrics unavailable".to_string());
1228 }
1229 let nx = ((f64::from(pt.x - vx)) * 65535.0 / f64::from(vw - 1)) as i32;
1230 let ny = ((f64::from(pt.y - vy)) * 65535.0 / f64::from(vh - 1)) as i32;
1231 (nx, ny)
1232 };
1233 let make = |flags: MOUSE_EVENT_FLAGS| INPUT {
1234 r#type: INPUT_MOUSE,
1235 Anonymous: INPUT_0 {
1236 mi: MOUSEINPUT {
1237 dx: nx,
1238 dy: ny,
1239 mouseData: 0,
1240 dwFlags: flags,
1241 time: 0,
1242 dwExtraInfo: 0,
1243 },
1244 },
1245 };
1246 let base = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK;
1247 let inputs = [
1248 make(base | MOUSEEVENTF_MOVE),
1249 make(base | MOUSEEVENTF_LEFTDOWN),
1250 make(base | MOUSEEVENTF_LEFTUP),
1251 ];
1252 let cb = i32::try_from(std::mem::size_of::<INPUT>()).unwrap_or(0);
1253 let sent = unsafe { SendInput(&inputs, cb) } as usize;
1255 if sent == inputs.len() {
1256 Ok(())
1257 } else {
1258 Err(format!(
1259 "SendInput delivered {sent}/{} mouse events",
1260 inputs.len()
1261 ))
1262 }
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267 use super::{dispatch_and_wait, is_main_thread, record_main_thread};
1268 use std::sync::Arc;
1269 use std::sync::atomic::{AtomicUsize, Ordering};
1270 use std::time::Duration;
1271
1272 fn post_after(delay: Duration) -> impl FnOnce(Box<dyn FnOnce() + Send>) -> Result<(), String> {
1274 move |job| {
1275 std::thread::spawn(move || {
1276 std::thread::sleep(delay);
1277 job();
1278 });
1279 Ok(())
1280 }
1281 }
1282
1283 fn post_started(
1289 started: std::sync::mpsc::Receiver<()>,
1290 ) -> impl FnOnce(Box<dyn FnOnce() + Send>) -> Result<(), String> {
1291 move |job| {
1292 std::thread::spawn(job);
1293 started
1294 .recv_timeout(Duration::from_secs(60))
1295 .map_err(|_| "the stand-in main thread never started the job".to_string())
1296 }
1297 }
1298
1299 #[test]
1300 fn a_job_still_queued_at_the_deadline_never_runs() {
1301 let ran = Arc::new(AtomicUsize::new(0));
1302 let r = Arc::clone(&ran);
1303 let out = dispatch_and_wait(
1304 "resize_window",
1305 Duration::from_millis(50),
1306 Duration::from_secs(5),
1307 post_after(Duration::from_millis(300)),
1308 move || r.fetch_add(1, Ordering::SeqCst),
1309 );
1310 assert!(out.unwrap_err().contains("will not run"));
1311 std::thread::sleep(Duration::from_millis(500));
1312 assert_eq!(ran.load(Ordering::SeqCst), 0, "an abandoned job ran anyway");
1313 }
1314
1315 #[test]
1316 fn a_job_running_at_the_deadline_reports_its_real_outcome() {
1317 let (started_tx, started_rx) = std::sync::mpsc::channel();
1320 let out = dispatch_and_wait(
1321 "eval_webview",
1322 Duration::from_millis(50),
1323 Duration::from_secs(5),
1324 post_started(started_rx),
1325 move || {
1326 let _ = started_tx.send(());
1327 std::thread::sleep(Duration::from_millis(300));
1328 7
1329 },
1330 );
1331 assert_eq!(out, Ok(7));
1332 }
1333
1334 #[test]
1335 fn trusted_input_is_refused_unless_the_target_is_in_front() {
1336 use super::{click_target_verdict, foreground_verdict};
1337 assert!(foreground_verdict(0x42, 0x42).is_ok());
1338 let other = foreground_verdict(0x42, 0x99).unwrap_err();
1339 assert!(other.contains("foreground"), "{other}");
1340 assert!(foreground_verdict(0x42, 0).is_err(), "no foreground window");
1341 assert!(foreground_verdict(0, 0).is_err(), "unknown target window");
1342 assert!(click_target_verdict(0x42, 0x42).is_ok());
1343 assert!(
1344 click_target_verdict(0x42, 0x99)
1345 .unwrap_err()
1346 .contains("covered")
1347 );
1348 assert!(click_target_verdict(0, 0).is_err());
1349 }
1350
1351 fn leaked_gate() -> &'static super::DispatchGate {
1352 Box::leak(Box::new(super::DispatchGate::new()))
1353 }
1354
1355 #[test]
1360 fn a_job_outliving_its_caller_blocks_the_next_round_trip_until_it_finishes() {
1361 use super::serialized_round_trip;
1362 use std::time::Instant;
1363 let gate = leaked_gate();
1364 let first_end = Arc::new(std::sync::Mutex::new(None::<Instant>));
1365 let fe = Arc::clone(&first_end);
1366 let (started_tx, started_rx) = std::sync::mpsc::channel();
1369 let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
1370 let first = serialized_round_trip(
1371 gate,
1372 "first",
1373 Duration::from_millis(50),
1374 post_started(started_rx),
1375 move || {
1376 let _ = started_tx.send(());
1377 let _ = release_rx.recv();
1378 *fe.lock().unwrap() = Some(Instant::now());
1379 },
1380 );
1381 assert!(
1382 first.unwrap_err().contains("outcome is unknown"),
1383 "precondition: the first caller gave up while its job was running"
1384 );
1385 let releaser = std::thread::spawn(move || {
1388 while gate.lock.try_lock().is_ok() {
1389 std::thread::sleep(Duration::from_millis(1));
1390 }
1391 std::thread::sleep(Duration::from_millis(20));
1392 release_tx.send(()).unwrap();
1393 });
1394 let second_start = serialized_round_trip(
1395 gate,
1396 "second",
1397 Duration::from_secs(5),
1398 post_after(Duration::ZERO),
1399 Instant::now,
1400 )
1401 .expect("the second round trip runs once the first job is done");
1402 releaser.join().unwrap();
1403 let first_end = first_end
1404 .lock()
1405 .unwrap()
1406 .expect("the first job finished before the second started");
1407 assert!(
1408 second_start >= first_end,
1409 "second round trip started {:?} before the abandoned first job finished",
1410 first_end - second_start
1411 );
1412 }
1413
1414 #[test]
1417 fn waiting_for_an_abandoned_job_is_bounded_by_the_callers_deadline() {
1418 use super::serialized_round_trip;
1419 let gate = leaked_gate();
1420 let (started_tx, started_rx) = std::sync::mpsc::channel();
1421 let first = serialized_round_trip(
1422 gate,
1423 "first",
1424 Duration::from_millis(30),
1425 post_started(started_rx),
1426 move || {
1427 let _ = started_tx.send(());
1428 std::thread::sleep(Duration::from_millis(1500));
1429 },
1430 );
1431 assert!(first.unwrap_err().contains("outcome is unknown"));
1432 let ran = Arc::new(AtomicUsize::new(0));
1433 let r = Arc::clone(&ran);
1434 let started = std::time::Instant::now();
1435 let second = serialized_round_trip(
1436 gate,
1437 "second",
1438 Duration::from_millis(200),
1439 post_after(Duration::ZERO),
1440 move || r.fetch_add(1, Ordering::SeqCst),
1441 );
1442 let waited = started.elapsed();
1443 let err = second.unwrap_err();
1444 assert!(err.contains("still running"), "{err}");
1445 assert!(waited < Duration::from_millis(1000), "waited {waited:?}");
1446 std::thread::sleep(Duration::from_millis(1600));
1447 assert_eq!(
1448 ran.load(Ordering::SeqCst),
1449 0,
1450 "a caller that gave up ran its job"
1451 );
1452 }
1453
1454 #[test]
1457 fn trusted_click_must_land_inside_the_client_area() {
1458 use super::client_point_verdict;
1459 assert!(client_point_verdict(0.0, 0.0, 800, 600).is_ok());
1460 assert!(client_point_verdict(799.9, 599.9, 800, 600).is_ok());
1461 for (x, y) in [
1462 (-1.0, 10.0), (10.0, -30.0), (800.0, 10.0), (10.0, 600.0),
1466 (f64::NAN, 10.0), (10.0, f64::INFINITY),
1468 (1e12, 10.0), ] {
1470 let err = client_point_verdict(x, y, 800, 600).unwrap_err();
1471 assert!(err.contains("outside"), "({x}, {y}): {err}");
1472 }
1473 assert!(
1474 client_point_verdict(0.0, 0.0, 0, 0).is_err(),
1475 "empty client area"
1476 );
1477 }
1478
1479 #[test]
1480 fn a_panicking_job_is_an_error_not_a_hang() {
1481 let out: Result<(), String> = dispatch_and_wait(
1482 "get_native_handle",
1483 Duration::from_secs(5),
1484 Duration::from_secs(5),
1485 post_after(Duration::ZERO),
1486 || panic!("boom"),
1487 );
1488 assert!(out.unwrap_err().contains("panicked"));
1489 }
1490
1491 struct FakeMainThread {
1494 jobs: std::sync::mpsc::Sender<Box<dyn FnOnce() + Send>>,
1495 id: std::thread::ThreadId,
1496 }
1497
1498 impl FakeMainThread {
1499 fn start() -> Self {
1500 let (jobs, rx) = std::sync::mpsc::channel::<Box<dyn FnOnce() + Send>>();
1501 let (id_tx, id_rx) = std::sync::mpsc::channel();
1502 std::thread::spawn(move || {
1503 id_tx.send(std::thread::current().id()).unwrap();
1504 while let Ok(job) = rx.recv() {
1505 job();
1506 }
1507 });
1508 Self {
1509 jobs,
1510 id: id_rx.recv().unwrap(),
1511 }
1512 }
1513
1514 fn post(&self) -> impl Fn(Box<dyn FnOnce() + Send>) -> Result<(), String> + '_ {
1515 move |job| {
1516 if std::thread::current().id() == self.id {
1517 job();
1518 Ok(())
1519 } else {
1520 self.jobs.send(job).map_err(|e| e.to_string())
1521 }
1522 }
1523 }
1524
1525 fn run<T: Send + 'static>(&self, f: impl FnOnce() -> T + Send + 'static) -> T {
1527 let (tx, rx) = std::sync::mpsc::channel();
1528 self.jobs
1529 .send(Box::new(move || tx.send(f()).unwrap()))
1530 .unwrap();
1531 rx.recv().unwrap()
1532 }
1533 }
1534
1535 type Adopted = Arc<std::sync::Mutex<Vec<(std::thread::ThreadId, String)>>>;
1536
1537 fn recording_adopt(log: &Adopted) -> impl Fn(&String) + Send + 'static {
1538 let log = Arc::clone(log);
1539 move |h: &String| {
1540 log.lock()
1541 .unwrap()
1542 .push((std::thread::current().id(), h.clone()));
1543 }
1544 }
1545
1546 #[test]
1552 fn setup_off_the_main_thread_adopts_the_real_main_thread() {
1553 let main = FakeMainThread::start();
1554 let log: Adopted = Arc::default();
1555 let handle = "app".to_string();
1556 let outcome = super::adopt_on_main(&handle, main.post(), recording_adopt(&log));
1557 assert_eq!(outcome, Ok(super::MainThreadAdoption::Posted));
1558 main.run(|| ());
1560 let adopted = log.lock().unwrap().clone();
1561 assert_eq!(adopted, vec![(main.id, "app".to_string())]);
1562 }
1563
1564 #[test]
1566 fn setup_on_the_main_thread_adopts_inline() {
1567 let main = Arc::new(FakeMainThread::start());
1568 let log: Adopted = Arc::default();
1569 let (m, l) = (Arc::clone(&main), Arc::clone(&log));
1570 let outcome = main.run(move || {
1571 let outcome = super::adopt_on_main(&"app".to_string(), m.post(), recording_adopt(&l));
1572 let adopted_now = l.lock().unwrap().len();
1574 (outcome, adopted_now)
1575 });
1576 assert_eq!(outcome, (Ok(super::MainThreadAdoption::Inline), 1));
1577 assert_eq!(log.lock().unwrap()[0].0, main.id);
1578 }
1579
1580 #[test]
1581 fn main_thread_is_recorded_once_and_only_that_thread_matches() {
1582 std::thread::spawn(|| {
1585 record_main_thread();
1586 assert!(is_main_thread(), "the recording thread is the main thread");
1587 })
1588 .join()
1589 .unwrap();
1590 assert!(
1591 !is_main_thread(),
1592 "a different thread must not be treated as main"
1593 );
1594 std::thread::spawn(|| assert!(!is_main_thread()))
1595 .join()
1596 .unwrap();
1597 std::thread::spawn(record_main_thread).join().unwrap();
1599 assert!(!is_main_thread());
1600 }
1601}