use tauri::{Manager, Runtime};
use victauri_core::WindowState;
pub trait WebviewBridge: Send + Sync {
fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String>;
fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState>;
fn try_get_window_states(&self, label: Option<&str>) -> Result<Vec<WindowState>, String> {
Ok(self.get_window_states(label))
}
fn list_window_labels(&self) -> Vec<String>;
fn try_list_window_labels(&self) -> Result<Vec<String>, String> {
Ok(self.list_window_labels())
}
fn eval_webview_resolved(&self, label: Option<&str>, script: &str) -> Result<String, String> {
self.eval_webview(label, script)?;
Ok(label.map(str::to_string).unwrap_or_default())
}
fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String>;
fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String>;
fn resize_window(&self, label: Option<&str>, width: u32, height: u32) -> Result<(), String>;
fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String>;
fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String>;
fn native_type_text(&self, _label: Option<&str>, _text: &str) -> Result<(), String> {
Err(
"native (trusted) keyboard input is not implemented on this platform; \
use synthetic input via the `input` tool without `trusted`"
.to_string(),
)
}
fn native_key(&self, _label: Option<&str>, _key: &str) -> Result<(), String> {
Err(
"native (trusted) key input is not implemented on this platform; \
use synthetic input via the `input` tool without `trusted`"
.to_string(),
)
}
fn native_click(&self, _label: Option<&str>, _x: f64, _y: f64) -> Result<(), String> {
Err(
"native (trusted) mouse input is not implemented on this platform; \
use synthetic input via the `interact` tool"
.to_string(),
)
}
fn app_data_dir(&self) -> Result<std::path::PathBuf, String> {
Err("backend access not available".to_string())
}
fn app_config_dir(&self) -> Result<std::path::PathBuf, String> {
Err("backend access not available".to_string())
}
fn app_log_dir(&self) -> Result<std::path::PathBuf, String> {
Err("backend access not available".to_string())
}
fn app_local_data_dir(&self) -> Result<std::path::PathBuf, String> {
Err("backend access not available".to_string())
}
#[must_use]
fn tauri_config(&self) -> serde_json::Value {
serde_json::Value::Null
}
}
fn find_window<'a, R: Runtime>(
windows: &'a std::collections::HashMap<String, tauri::WebviewWindow<R>>,
label: Option<&str>,
) -> Result<&'a tauri::WebviewWindow<R>, String> {
match label {
Some(l) => windows
.get(l)
.ok_or_else(|| format!("window not found: {l}")),
None => windows
.get("main")
.or_else(|| {
let mut visible: Vec<_> = windows
.iter()
.filter(|(_, w)| w.is_visible().unwrap_or(false))
.collect();
visible.sort_by(|a, b| a.0.cmp(b.0));
visible.first().map(|(_, w)| *w)
})
.or_else(|| windows.iter().min_by(|a, b| a.0.cmp(b.0)).map(|(_, w)| w))
.ok_or_else(|| "no window available".to_string()),
}
}
pub(crate) fn default_window_label<R: Runtime>(app: &tauri::AppHandle<R>) -> Option<String> {
let windows = app.webview_windows();
find_window(&windows, None)
.ok()
.map(|window| window.label().to_string())
}
static MAIN_DISPATCH_LOCK: DispatchGate = DispatchGate::new();
struct DispatchGate {
lock: std::sync::Mutex<()>,
in_flight: std::sync::atomic::AtomicUsize,
}
impl DispatchGate {
const fn new() -> Self {
Self {
lock: std::sync::Mutex::new(()),
in_flight: std::sync::atomic::AtomicUsize::new(0),
}
}
}
struct InFlightGuard(&'static DispatchGate);
impl Drop for InFlightGuard {
fn drop(&mut self) {
self.0
.in_flight
.fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
}
}
static MAIN_THREAD: std::sync::OnceLock<std::thread::ThreadId> = std::sync::OnceLock::new();
fn record_main_thread() {
let _ = MAIN_THREAD.set(std::thread::current().id());
}
thread_local! {
static MAIN_APP: std::cell::RefCell<Option<Box<dyn std::any::Any>>> =
const { std::cell::RefCell::new(None) };
}
fn install_main_app<R: Runtime>(app: &tauri::AppHandle<R>) {
MAIN_APP.with(|slot| *slot.borrow_mut() = Some(Box::new(app.clone())));
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MainThreadAdoption {
Inline,
Posted,
}
pub(crate) fn adopt_main_thread<R: Runtime>(
app: &tauri::AppHandle<R>,
) -> Result<MainThreadAdoption, String> {
adopt_on_main(
app,
|job| app.run_on_main_thread(job).map_err(|e| e.to_string()),
|main: &tauri::AppHandle<R>| {
record_main_thread();
install_main_app(main);
},
)
}
fn adopt_on_main<H: Clone + Send + 'static>(
handle: &H,
post: impl Fn(Box<dyn FnOnce() + Send>) -> Result<(), String>,
adopt: impl Fn(&H) + Send + 'static,
) -> Result<MainThreadAdoption, String> {
use std::sync::atomic::{AtomicBool, Ordering};
let here = std::thread::current().id();
let ran_here = std::sync::Arc::new(AtomicBool::new(false));
let flag = std::sync::Arc::clone(&ran_here);
post(Box::new(move || {
if std::thread::current().id() == here {
flag.store(true, Ordering::Release);
}
}))?;
if ran_here.load(Ordering::Acquire) {
adopt(handle);
return Ok(MainThreadAdoption::Inline);
}
let owned = handle.clone();
post(Box::new(move || adopt(&owned)))?;
Ok(MainThreadAdoption::Posted)
}
fn main_app<R: Runtime>() -> Option<tauri::AppHandle<R>> {
MAIN_APP.with(|slot| {
slot.borrow()
.as_ref()
.and_then(|any| any.downcast_ref::<tauri::AppHandle<R>>())
.cloned()
})
}
fn is_main_thread() -> bool {
MAIN_THREAD
.get()
.is_some_and(|id| *id == std::thread::current().id())
}
fn on_main<R, T, F>(app: &tauri::AppHandle<R>, what: &str, f: F) -> Result<T, String>
where
R: Runtime,
T: Send + 'static,
F: FnOnce(&tauri::AppHandle<R>) -> T + Send + 'static,
{
let timeout = std::time::Duration::from_secs(10);
if is_main_thread() {
return std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(app)))
.map_err(|_| format!("{what} panicked on the main thread"));
}
let round_trip = move || -> Result<T, String> {
let owned_what = what.to_string();
serialized_round_trip(
&MAIN_DISPATCH_LOCK,
what,
timeout,
|job| {
app.run_on_main_thread(job)
.map_err(|e| format!("failed to dispatch {what} to the main thread: {e}"))
},
move || {
main_app::<R>().map(|main| f(&main)).ok_or_else(|| {
format!(
"{owned_what}: the main-thread app handle is not installed (the plugin \
setup has not run)"
)
})
},
)?
};
match tokio::runtime::Handle::try_current().map(|h| h.runtime_flavor()) {
Ok(tokio::runtime::RuntimeFlavor::MultiThread) => tokio::task::block_in_place(round_trip),
_ => round_trip(),
}
}
fn serialized_round_trip<T, F>(
gate: &'static DispatchGate,
what: &str,
timeout: std::time::Duration,
post: impl FnOnce(Box<dyn FnOnce() + Send>) -> Result<(), String>,
f: F,
) -> Result<T, String>
where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
{
let deadline = std::time::Instant::now() + timeout;
let lock_timeout = || {
format!(
"{what} did not complete on the main thread: timed out waiting for the main-thread \
dispatch lock"
)
};
let _serialize = loop {
match gate.lock.try_lock() {
Ok(guard) => break guard,
Err(std::sync::TryLockError::Poisoned(poisoned)) => break poisoned.into_inner(),
Err(std::sync::TryLockError::WouldBlock) => {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
return Err(lock_timeout());
}
std::thread::sleep(remaining.min(std::time::Duration::from_millis(2)));
}
}
};
while gate.in_flight.load(std::sync::atomic::Ordering::Acquire) != 0 {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
return Err(format!(
"{what} did not complete on the main thread: timed out waiting for an earlier \
main-thread call that is still running (it will not run)"
));
}
std::thread::sleep(remaining.min(std::time::Duration::from_millis(2)));
}
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
return Err(lock_timeout());
}
let counted_post = move |job: Box<dyn FnOnce() + Send>| {
post(Box::new(move || {
gate.in_flight
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
let _done = InFlightGuard(gate);
job();
}))
};
dispatch_and_wait(what, remaining, timeout, counted_post, f)
}
const JOB_QUEUED: u8 = 0;
const JOB_RUNNING: u8 = 1;
const JOB_ABANDONED: u8 = 2;
fn dispatch_and_wait<T, F>(
what: &str,
remaining: std::time::Duration,
grace: std::time::Duration,
post: impl FnOnce(Box<dyn FnOnce() + Send>) -> Result<(), String>,
f: F,
) -> Result<T, String>
where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
{
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::mpsc::RecvTimeoutError;
let (tx, rx) = std::sync::mpsc::channel();
let state = std::sync::Arc::new(AtomicU8::new(JOB_QUEUED));
let job_state = std::sync::Arc::clone(&state);
post(Box::new(move || {
if job_state
.compare_exchange(JOB_QUEUED, JOB_RUNNING, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return;
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
let _ = tx.send(result);
}))?;
let outcome = match rx.recv_timeout(remaining) {
Ok(outcome) => outcome,
Err(RecvTimeoutError::Disconnected) => {
return Err(format!(
"{what} did not complete on the main thread: the event loop dropped it"
));
}
Err(RecvTimeoutError::Timeout) => {
if state
.compare_exchange(
JOB_QUEUED,
JOB_ABANDONED,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
return Err(format!(
"{what} did not complete on the main thread: timed out before it started \
(it will not run)"
));
}
rx.recv_timeout(grace).map_err(|_| {
format!(
"{what} started on the main thread but did not finish in time; its outcome \
is unknown"
)
})?
}
};
outcome.map_err(|_panic| format!("{what} panicked on the main thread"))
}
impl<R: Runtime> WebviewBridge for tauri::AppHandle<R> {
fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
let label = label.map(str::to_string);
let script = script.to_string();
on_main(self, "eval_webview", move |app| {
let windows = app.webview_windows();
let webview = find_window(&windows, label.as_deref())?;
webview.eval(&script).map_err(|e| e.to_string())
})?
}
fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
self.try_get_window_states(label).unwrap_or_else(|e| {
tracing::warn!("get_window_states: {e}");
Vec::new()
})
}
fn try_get_window_states(&self, label: Option<&str>) -> Result<Vec<WindowState>, String> {
let label = label.map(str::to_string);
on_main(self, "get_window_states", move |app| {
let windows = app.webview_windows();
let mut states = Vec::new();
for (win_label, window) in &windows {
if let Some(filter) = label.as_deref()
&& win_label != filter
{
continue;
}
let pos = window.outer_position().unwrap_or_default();
let size = window.inner_size().unwrap_or_default();
states.push(
WindowState::new(win_label.clone())
.with_title(window.title().unwrap_or_default())
.with_url(window.url().map(|u| u.to_string()).unwrap_or_default())
.with_visible(window.is_visible().unwrap_or(false))
.with_focused(window.is_focused().unwrap_or(false))
.with_maximized(window.is_maximized().unwrap_or(false))
.with_minimized(window.is_minimized().unwrap_or(false))
.with_fullscreen(window.is_fullscreen().unwrap_or(false))
.with_position(pos.x, pos.y)
.with_size(size.width, size.height),
);
}
states
})
}
fn list_window_labels(&self) -> Vec<String> {
self.try_list_window_labels().unwrap_or_else(|e| {
tracing::warn!("list_window_labels: {e}");
Vec::new()
})
}
fn try_list_window_labels(&self) -> Result<Vec<String>, String> {
on_main(self, "list_window_labels", |app| {
app.webview_windows().keys().cloned().collect()
})
}
fn eval_webview_resolved(&self, label: Option<&str>, script: &str) -> Result<String, String> {
let label = label.map(str::to_string);
let script = script.to_string();
on_main(self, "eval_webview", move |app| {
let windows = app.webview_windows();
let webview = find_window(&windows, label.as_deref())?;
webview.eval(&script).map_err(|e| e.to_string())?;
Ok(webview.label().to_string())
})?
}
fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
let label = label.map(str::to_string);
on_main(self, "get_native_handle", move |app| {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
let windows = app.webview_windows();
let _webview = find_window(&windows, label.as_deref())?;
let handle = _webview.window_handle().map_err(|e| e.to_string())?;
match handle.as_raw() {
#[cfg(windows)]
RawWindowHandle::Win32(h) => Ok(h.hwnd.get()),
#[cfg(target_os = "macos")]
RawWindowHandle::AppKit(h) => {
macos_window_number(h.ns_view.as_ptr())
}
#[cfg(target_os = "linux")]
RawWindowHandle::Xlib(h) => Ok(h.window as isize),
#[cfg(target_os = "linux")]
RawWindowHandle::Xcb(h) => Ok(h.window.get() as isize),
_ => Err("unsupported window handle type on this platform".to_string()),
}
})?
}
#[cfg(windows)]
fn native_type_text(&self, label: Option<&str>, text: &str) -> Result<(), String> {
let hwnd = self.get_native_handle(label)?;
win_focus(hwnd)?;
win_send_text(text)
}
#[cfg(windows)]
fn native_key(&self, label: Option<&str>, key: &str) -> Result<(), String> {
let hwnd = self.get_native_handle(label)?;
win_focus(hwnd)?;
win_send_key(key)
}
#[cfg(windows)]
fn native_click(&self, label: Option<&str>, x: f64, y: f64) -> Result<(), String> {
let hwnd = self.get_native_handle(label)?;
win_focus(hwnd)?;
win_click(hwnd, x, y)
}
fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
let label = label.map(str::to_string);
let action = action.to_string();
on_main(self, "manage_window", move |app| {
let windows = app.webview_windows();
let window = find_window(&windows, label.as_deref())?;
match action.as_str() {
"minimize" => window.minimize().map_err(|e| e.to_string())?,
"unminimize" => window.unminimize().map_err(|e| e.to_string())?,
"maximize" => window.maximize().map_err(|e| e.to_string())?,
"unmaximize" => window.unmaximize().map_err(|e| e.to_string())?,
"close" => window.close().map_err(|e| e.to_string())?,
"focus" => window.set_focus().map_err(|e| e.to_string())?,
"show" => window.show().map_err(|e| e.to_string())?,
"hide" => window.hide().map_err(|e| e.to_string())?,
"fullscreen" => window.set_fullscreen(true).map_err(|e| e.to_string())?,
"unfullscreen" => window.set_fullscreen(false).map_err(|e| e.to_string())?,
"always_on_top" => window.set_always_on_top(true).map_err(|e| e.to_string())?,
"not_always_on_top" => {
window.set_always_on_top(false).map_err(|e| e.to_string())?;
}
_ => return Err(format!("unknown action: {action}")),
}
Ok(format!("{action} executed"))
})?
}
fn resize_window(&self, label: Option<&str>, width: u32, height: u32) -> Result<(), String> {
let label = label.map(str::to_string);
on_main(self, "resize_window", move |app| {
let windows = app.webview_windows();
let window = find_window(&windows, label.as_deref())?;
window
.set_size(tauri::LogicalSize::new(width, height))
.map_err(|e| e.to_string())
})?
}
fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
let label = label.map(str::to_string);
on_main(self, "move_window", move |app| {
let windows = app.webview_windows();
let window = find_window(&windows, label.as_deref())?;
window
.set_position(tauri::LogicalPosition::new(x, y))
.map_err(|e| e.to_string())
})?
}
fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
let label = label.map(str::to_string);
let title = title.to_string();
on_main(self, "set_window_title", move |app| {
let windows = app.webview_windows();
let window = find_window(&windows, label.as_deref())?;
window.set_title(&title).map_err(|e| e.to_string())
})?
}
fn app_data_dir(&self) -> Result<std::path::PathBuf, String> {
self.path().app_data_dir().map_err(|e| e.to_string())
}
fn app_config_dir(&self) -> Result<std::path::PathBuf, String> {
self.path().app_config_dir().map_err(|e| e.to_string())
}
fn app_log_dir(&self) -> Result<std::path::PathBuf, String> {
self.path().app_log_dir().map_err(|e| e.to_string())
}
fn app_local_data_dir(&self) -> Result<std::path::PathBuf, String> {
self.path().app_local_data_dir().map_err(|e| e.to_string())
}
fn tauri_config(&self) -> serde_json::Value {
let config = self.config();
let windows: Vec<serde_json::Value> = config
.app
.windows
.iter()
.map(|w| {
serde_json::json!({
"label": w.label,
"title": w.title,
"url": format!("{}", w.url),
"width": w.width,
"height": w.height,
"visible": w.visible,
"resizable": w.resizable,
"fullscreen": w.fullscreen,
"decorations": w.decorations,
"transparent": w.transparent,
"always_on_top": w.always_on_top,
})
})
.collect();
let plugins: Vec<String> = config.plugins.0.keys().cloned().collect();
let security = serde_json::json!({
"csp": config.app.security.csp.as_ref().map(|c| format!("{c}")),
"freeze_prototype": config.app.security.freeze_prototype,
"capabilities": config.app.security.capabilities.iter().map(|c| {
match c {
tauri::utils::config::CapabilityEntry::Inlined(cap) => {
serde_json::json!({
"identifier": cap.identifier,
"description": cap.description,
"windows": cap.windows,
"webviews": cap.webviews,
"permissions": cap.permissions.iter().map(|p| format!("{p:?}")).collect::<Vec<_>>(),
"platforms": cap.platforms,
})
}
tauri::utils::config::CapabilityEntry::Reference(path) => {
serde_json::json!({ "reference": path })
}
}
}).collect::<Vec<_>>(),
});
serde_json::json!({
"identifier": config.identifier,
"product_name": config.product_name,
"version": config.version,
"windows": windows,
"plugins": plugins,
"security": security,
})
}
}
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
fn macos_window_number(ns_view: *mut std::ffi::c_void) -> Result<isize, String> {
unsafe extern "C" {
fn objc_msgSend(obj: *mut std::ffi::c_void, sel: *mut std::ffi::c_void) -> isize;
fn sel_registerName(name: *const std::ffi::c_char) -> *mut std::ffi::c_void;
}
if ns_view.is_null() {
return Err("null NSView handle".to_string());
}
unsafe {
let sel_window = sel_registerName(c"window".as_ptr());
let ns_window = objc_msgSend(ns_view, sel_window);
if ns_window == 0 {
return Err("NSView has no parent NSWindow".to_string());
}
let sel_window_number = sel_registerName(c"windowNumber".as_ptr());
let ns_window_ptr = ns_window as *mut std::ffi::c_void;
let window_number = objc_msgSend(ns_window_ptr, sel_window_number);
if window_number <= 0 {
return Err(format!("invalid CGWindowID: {window_number}"));
}
Ok(window_number)
}
}
#[cfg(windows)]
fn win_hwnd(hwnd: isize) -> windows::Win32::Foundation::HWND {
windows::Win32::Foundation::HWND(hwnd as *mut core::ffi::c_void)
}
#[cfg(any(windows, test))]
fn foreground_verdict(target_root: isize, foreground_root: isize) -> Result<(), String> {
if target_root != 0 && target_root == foreground_root {
Ok(())
} else {
Err(
"refusing trusted input: the app window could not be brought to the foreground \
(Windows' foreground lock keeps focus with the app the user is working in, e.g. a \
terminal), so the keystrokes or click would go to that app instead. Bring the app \
window to the front and retry, or omit `trusted` to use synthetic input."
.to_string(),
)
}
}
#[cfg(any(windows, test))]
fn click_target_verdict(target_root: isize, point_root: isize) -> Result<(), String> {
if target_root != 0 && target_root == point_root {
Ok(())
} else {
Err(
"refusing trusted click: the point is covered by another window (or lies outside \
this one), so the click would land there. Make the element visible on screen and \
retry, or omit `trusted` to use a synthetic click."
.to_string(),
)
}
}
#[cfg(any(windows, test))]
fn client_point_verdict(x: f64, y: f64, client_w: i32, client_h: i32) -> Result<(), String> {
let inside = x.is_finite()
&& y.is_finite()
&& x >= 0.0
&& y >= 0.0
&& x < f64::from(client_w)
&& y < f64::from(client_h);
if inside {
Ok(())
} else {
Err(format!(
"refusing trusted click: ({x}, {y}) lies outside the window's content area \
({client_w}x{client_h} physical px), so it would land on the title bar, a caption \
button or the border. Scroll the element into view and retry, or omit `trusted` to \
use a synthetic click."
))
}
}
#[allow(unsafe_code)]
#[cfg(windows)]
fn win_root(hwnd: windows::Win32::Foundation::HWND) -> isize {
use windows::Win32::UI::WindowsAndMessaging::{GA_ROOT, GetAncestor};
unsafe { GetAncestor(hwnd, GA_ROOT) }.0 as isize
}
#[allow(unsafe_code)]
#[cfg(windows)]
fn win_focus(hwnd: isize) -> Result<(), String> {
use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, SetForegroundWindow};
let target = win_hwnd(hwnd);
let target_root = win_root(target);
unsafe {
let _ = SetForegroundWindow(target);
}
std::thread::sleep(std::time::Duration::from_millis(40));
let mut verdict = Ok(());
for _ in 0..8 {
verdict = foreground_verdict(target_root, win_root(unsafe { GetForegroundWindow() }));
if verdict.is_ok() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
verdict
}
#[cfg(windows)]
fn win_keyboard_input(
vk: u16,
scan: u16,
key_up: bool,
unicode: bool,
) -> windows::Win32::UI::Input::KeyboardAndMouse::INPUT {
use windows::Win32::UI::Input::KeyboardAndMouse::{
INPUT, INPUT_0, INPUT_KEYBOARD, KEYBD_EVENT_FLAGS, KEYBDINPUT, KEYEVENTF_KEYUP,
KEYEVENTF_UNICODE, VIRTUAL_KEY,
};
let mut flags = KEYBD_EVENT_FLAGS(0);
if unicode {
flags |= KEYEVENTF_UNICODE;
}
if key_up {
flags |= KEYEVENTF_KEYUP;
}
INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: INPUT_0 {
ki: KEYBDINPUT {
wVk: VIRTUAL_KEY(vk),
wScan: scan,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
},
},
}
}
#[allow(unsafe_code)]
#[cfg(windows)]
fn win_send_text(text: &str) -> Result<(), String> {
use windows::Win32::UI::Input::KeyboardAndMouse::{INPUT, SendInput};
let mut inputs: Vec<INPUT> = Vec::new();
for unit in text.encode_utf16() {
inputs.push(win_keyboard_input(0, unit, false, true));
inputs.push(win_keyboard_input(0, unit, true, true));
}
if inputs.is_empty() {
return Ok(());
}
let cb = i32::try_from(std::mem::size_of::<INPUT>()).unwrap_or(0);
let sent = unsafe { SendInput(&inputs, cb) } as usize;
if sent == inputs.len() {
Ok(())
} else {
Err(format!(
"SendInput delivered {sent}/{} key events",
inputs.len()
))
}
}
#[cfg(windows)]
fn win_vk_for_key(key: &str) -> Option<u16> {
use windows::Win32::UI::Input::KeyboardAndMouse as k;
let vk = match key {
"Enter" | "Return" => k::VK_RETURN,
"Tab" => k::VK_TAB,
"Escape" | "Esc" => k::VK_ESCAPE,
"Backspace" => k::VK_BACK,
"Delete" | "Del" => k::VK_DELETE,
"ArrowUp" | "Up" => k::VK_UP,
"ArrowDown" | "Down" => k::VK_DOWN,
"ArrowLeft" | "Left" => k::VK_LEFT,
"ArrowRight" | "Right" => k::VK_RIGHT,
"Home" => k::VK_HOME,
"End" => k::VK_END,
"PageUp" => k::VK_PRIOR,
"PageDown" => k::VK_NEXT,
"Space" | " " => k::VK_SPACE,
"F1" => k::VK_F1,
"F2" => k::VK_F2,
"F3" => k::VK_F3,
"F4" => k::VK_F4,
"F5" => k::VK_F5,
"F6" => k::VK_F6,
"F7" => k::VK_F7,
"F8" => k::VK_F8,
"F9" => k::VK_F9,
"F10" => k::VK_F10,
"F11" => k::VK_F11,
"F12" => k::VK_F12,
_ => return None,
};
Some(vk.0)
}
#[allow(unsafe_code)]
#[cfg(windows)]
fn win_send_key(key: &str) -> Result<(), String> {
use windows::Win32::UI::Input::KeyboardAndMouse::{INPUT, SendInput};
let inputs: Vec<INPUT> = if let Some(vk) = win_vk_for_key(key) {
vec![
win_keyboard_input(vk, 0, false, false),
win_keyboard_input(vk, 0, true, false),
]
} else {
let mut chars = key.chars();
let (Some(c), None) = (chars.next(), chars.next()) else {
return Err(format!(
"unknown key '{key}' (use a named key or a single character)"
));
};
let mut buf = [0u16; 2];
let mut v = Vec::new();
for unit in c.encode_utf16(&mut buf) {
v.push(win_keyboard_input(0, *unit, false, true));
v.push(win_keyboard_input(0, *unit, true, true));
}
v
};
let cb = i32::try_from(std::mem::size_of::<INPUT>()).unwrap_or(0);
let sent = unsafe { SendInput(&inputs, cb) } as usize;
if sent == inputs.len() {
Ok(())
} else {
Err(format!(
"SendInput delivered {sent}/{} key events",
inputs.len()
))
}
}
#[allow(unsafe_code)]
#[cfg(windows)]
fn win_click(hwnd: isize, x: f64, y: f64) -> Result<(), String> {
use windows::Win32::Foundation::POINT;
use windows::Win32::Foundation::RECT;
use windows::Win32::Graphics::Gdi::ClientToScreen;
use windows::Win32::UI::HiDpi::GetDpiForWindow;
use windows::Win32::UI::Input::KeyboardAndMouse::{
INPUT, INPUT_0, INPUT_MOUSE, MOUSE_EVENT_FLAGS, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_LEFTDOWN,
MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_VIRTUALDESK, MOUSEINPUT, SendInput,
};
use windows::Win32::UI::WindowsAndMessaging::{
GetClientRect, GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
SM_YVIRTUALSCREEN, WindowFromPoint,
};
let h = win_hwnd(hwnd);
let (nx, ny) = unsafe {
let dpi = GetDpiForWindow(h);
let scale = if dpi == 0 { 1.0 } else { f64::from(dpi) / 96.0 };
let (px, py) = (x * scale, y * scale);
let mut client = RECT::default();
GetClientRect(h, &mut client)
.map_err(|e| format!("refusing trusted click: cannot read the client area: {e}"))?;
client_point_verdict(px, py, client.right, client.bottom)?;
let mut pt = POINT {
x: px as i32,
y: py as i32,
};
let _ = ClientToScreen(h, &mut pt);
click_target_verdict(win_root(h), win_root(WindowFromPoint(pt)))?;
let vx = GetSystemMetrics(SM_XVIRTUALSCREEN);
let vy = GetSystemMetrics(SM_YVIRTUALSCREEN);
let vw = GetSystemMetrics(SM_CXVIRTUALSCREEN);
let vh = GetSystemMetrics(SM_CYVIRTUALSCREEN);
if vw <= 1 || vh <= 1 {
return Err("virtual screen metrics unavailable".to_string());
}
let nx = ((f64::from(pt.x - vx)) * 65535.0 / f64::from(vw - 1)) as i32;
let ny = ((f64::from(pt.y - vy)) * 65535.0 / f64::from(vh - 1)) as i32;
(nx, ny)
};
let make = |flags: MOUSE_EVENT_FLAGS| INPUT {
r#type: INPUT_MOUSE,
Anonymous: INPUT_0 {
mi: MOUSEINPUT {
dx: nx,
dy: ny,
mouseData: 0,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
},
},
};
let base = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK;
let inputs = [
make(base | MOUSEEVENTF_MOVE),
make(base | MOUSEEVENTF_LEFTDOWN),
make(base | MOUSEEVENTF_LEFTUP),
];
let cb = i32::try_from(std::mem::size_of::<INPUT>()).unwrap_or(0);
let sent = unsafe { SendInput(&inputs, cb) } as usize;
if sent == inputs.len() {
Ok(())
} else {
Err(format!(
"SendInput delivered {sent}/{} mouse events",
inputs.len()
))
}
}
#[cfg(test)]
mod tests {
use super::{dispatch_and_wait, is_main_thread, record_main_thread};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
fn post_after(delay: Duration) -> impl FnOnce(Box<dyn FnOnce() + Send>) -> Result<(), String> {
move |job| {
std::thread::spawn(move || {
std::thread::sleep(delay);
job();
});
Ok(())
}
}
fn post_started(
started: std::sync::mpsc::Receiver<()>,
) -> impl FnOnce(Box<dyn FnOnce() + Send>) -> Result<(), String> {
move |job| {
std::thread::spawn(job);
started
.recv_timeout(Duration::from_secs(60))
.map_err(|_| "the stand-in main thread never started the job".to_string())
}
}
#[test]
fn a_job_still_queued_at_the_deadline_never_runs() {
let ran = Arc::new(AtomicUsize::new(0));
let r = Arc::clone(&ran);
let out = dispatch_and_wait(
"resize_window",
Duration::from_millis(50),
Duration::from_secs(5),
post_after(Duration::from_millis(300)),
move || r.fetch_add(1, Ordering::SeqCst),
);
assert!(out.unwrap_err().contains("will not run"));
std::thread::sleep(Duration::from_millis(500));
assert_eq!(ran.load(Ordering::SeqCst), 0, "an abandoned job ran anyway");
}
#[test]
fn a_job_running_at_the_deadline_reports_its_real_outcome() {
let (started_tx, started_rx) = std::sync::mpsc::channel();
let out = dispatch_and_wait(
"eval_webview",
Duration::from_millis(50),
Duration::from_secs(5),
post_started(started_rx),
move || {
let _ = started_tx.send(());
std::thread::sleep(Duration::from_millis(300));
7
},
);
assert_eq!(out, Ok(7));
}
#[test]
fn trusted_input_is_refused_unless_the_target_is_in_front() {
use super::{click_target_verdict, foreground_verdict};
assert!(foreground_verdict(0x42, 0x42).is_ok());
let other = foreground_verdict(0x42, 0x99).unwrap_err();
assert!(other.contains("foreground"), "{other}");
assert!(foreground_verdict(0x42, 0).is_err(), "no foreground window");
assert!(foreground_verdict(0, 0).is_err(), "unknown target window");
assert!(click_target_verdict(0x42, 0x42).is_ok());
assert!(
click_target_verdict(0x42, 0x99)
.unwrap_err()
.contains("covered")
);
assert!(click_target_verdict(0, 0).is_err());
}
fn leaked_gate() -> &'static super::DispatchGate {
Box::leak(Box::new(super::DispatchGate::new()))
}
#[test]
fn a_job_outliving_its_caller_blocks_the_next_round_trip_until_it_finishes() {
use super::serialized_round_trip;
use std::time::Instant;
let gate = leaked_gate();
let first_end = Arc::new(std::sync::Mutex::new(None::<Instant>));
let fe = Arc::clone(&first_end);
let (started_tx, started_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let first = serialized_round_trip(
gate,
"first",
Duration::from_millis(50),
post_started(started_rx),
move || {
let _ = started_tx.send(());
let _ = release_rx.recv();
*fe.lock().unwrap() = Some(Instant::now());
},
);
assert!(
first.unwrap_err().contains("outcome is unknown"),
"precondition: the first caller gave up while its job was running"
);
let releaser = std::thread::spawn(move || {
while gate.lock.try_lock().is_ok() {
std::thread::sleep(Duration::from_millis(1));
}
std::thread::sleep(Duration::from_millis(20));
release_tx.send(()).unwrap();
});
let second_start = serialized_round_trip(
gate,
"second",
Duration::from_secs(5),
post_after(Duration::ZERO),
Instant::now,
)
.expect("the second round trip runs once the first job is done");
releaser.join().unwrap();
let first_end = first_end
.lock()
.unwrap()
.expect("the first job finished before the second started");
assert!(
second_start >= first_end,
"second round trip started {:?} before the abandoned first job finished",
first_end - second_start
);
}
#[test]
fn waiting_for_an_abandoned_job_is_bounded_by_the_callers_deadline() {
use super::serialized_round_trip;
let gate = leaked_gate();
let (started_tx, started_rx) = std::sync::mpsc::channel();
let first = serialized_round_trip(
gate,
"first",
Duration::from_millis(30),
post_started(started_rx),
move || {
let _ = started_tx.send(());
std::thread::sleep(Duration::from_millis(1500));
},
);
assert!(first.unwrap_err().contains("outcome is unknown"));
let ran = Arc::new(AtomicUsize::new(0));
let r = Arc::clone(&ran);
let started = std::time::Instant::now();
let second = serialized_round_trip(
gate,
"second",
Duration::from_millis(200),
post_after(Duration::ZERO),
move || r.fetch_add(1, Ordering::SeqCst),
);
let waited = started.elapsed();
let err = second.unwrap_err();
assert!(err.contains("still running"), "{err}");
assert!(waited < Duration::from_millis(1000), "waited {waited:?}");
std::thread::sleep(Duration::from_millis(1600));
assert_eq!(
ran.load(Ordering::SeqCst),
0,
"a caller that gave up ran its job"
);
}
#[test]
fn trusted_click_must_land_inside_the_client_area() {
use super::client_point_verdict;
assert!(client_point_verdict(0.0, 0.0, 800, 600).is_ok());
assert!(client_point_verdict(799.9, 599.9, 800, 600).is_ok());
for (x, y) in [
(-1.0, 10.0), (10.0, -30.0), (800.0, 10.0), (10.0, 600.0),
(f64::NAN, 10.0), (10.0, f64::INFINITY),
(1e12, 10.0), ] {
let err = client_point_verdict(x, y, 800, 600).unwrap_err();
assert!(err.contains("outside"), "({x}, {y}): {err}");
}
assert!(
client_point_verdict(0.0, 0.0, 0, 0).is_err(),
"empty client area"
);
}
#[test]
fn a_panicking_job_is_an_error_not_a_hang() {
let out: Result<(), String> = dispatch_and_wait(
"get_native_handle",
Duration::from_secs(5),
Duration::from_secs(5),
post_after(Duration::ZERO),
|| panic!("boom"),
);
assert!(out.unwrap_err().contains("panicked"));
}
struct FakeMainThread {
jobs: std::sync::mpsc::Sender<Box<dyn FnOnce() + Send>>,
id: std::thread::ThreadId,
}
impl FakeMainThread {
fn start() -> Self {
let (jobs, rx) = std::sync::mpsc::channel::<Box<dyn FnOnce() + Send>>();
let (id_tx, id_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
id_tx.send(std::thread::current().id()).unwrap();
while let Ok(job) = rx.recv() {
job();
}
});
Self {
jobs,
id: id_rx.recv().unwrap(),
}
}
fn post(&self) -> impl Fn(Box<dyn FnOnce() + Send>) -> Result<(), String> + '_ {
move |job| {
if std::thread::current().id() == self.id {
job();
Ok(())
} else {
self.jobs.send(job).map_err(|e| e.to_string())
}
}
}
fn run<T: Send + 'static>(&self, f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = std::sync::mpsc::channel();
self.jobs
.send(Box::new(move || tx.send(f()).unwrap()))
.unwrap();
rx.recv().unwrap()
}
}
type Adopted = Arc<std::sync::Mutex<Vec<(std::thread::ThreadId, String)>>>;
fn recording_adopt(log: &Adopted) -> impl Fn(&String) + Send + 'static {
let log = Arc::clone(log);
move |h: &String| {
log.lock()
.unwrap()
.push((std::thread::current().id(), h.clone()));
}
}
#[test]
fn setup_off_the_main_thread_adopts_the_real_main_thread() {
let main = FakeMainThread::start();
let log: Adopted = Arc::default();
let handle = "app".to_string();
let outcome = super::adopt_on_main(&handle, main.post(), recording_adopt(&log));
assert_eq!(outcome, Ok(super::MainThreadAdoption::Posted));
main.run(|| ());
let adopted = log.lock().unwrap().clone();
assert_eq!(adopted, vec![(main.id, "app".to_string())]);
}
#[test]
fn setup_on_the_main_thread_adopts_inline() {
let main = Arc::new(FakeMainThread::start());
let log: Adopted = Arc::default();
let (m, l) = (Arc::clone(&main), Arc::clone(&log));
let outcome = main.run(move || {
let outcome = super::adopt_on_main(&"app".to_string(), m.post(), recording_adopt(&l));
let adopted_now = l.lock().unwrap().len();
(outcome, adopted_now)
});
assert_eq!(outcome, (Ok(super::MainThreadAdoption::Inline), 1));
assert_eq!(log.lock().unwrap()[0].0, main.id);
}
#[test]
fn main_thread_is_recorded_once_and_only_that_thread_matches() {
std::thread::spawn(|| {
record_main_thread();
assert!(is_main_thread(), "the recording thread is the main thread");
})
.join()
.unwrap();
assert!(
!is_main_thread(),
"a different thread must not be treated as main"
);
std::thread::spawn(|| assert!(!is_main_thread()))
.join()
.unwrap();
std::thread::spawn(record_main_thread).join().unwrap();
assert!(!is_main_thread());
}
}