Skip to main content

victauri_plugin/
bridge.rs

1use tauri::{Manager, Runtime};
2use victauri_core::WindowState;
3
4/// Runtime-erased interface for webview and backend access, allowing the MCP
5/// server to interact with Tauri windows and the application backend without
6/// generic parameters.
7pub trait WebviewBridge: Send + Sync {
8    /// Execute JavaScript in the target webview (defaults to "main" or first visible window).
9    ///
10    /// # Errors
11    ///
12    /// Returns an error string if no matching window is found or the eval fails.
13    fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String>;
14    /// Retrieve the state of one or all windows (position, size, visibility, focus, URL).
15    fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState>;
16    /// Return the labels of all open webview windows.
17    fn list_window_labels(&self) -> Vec<String>;
18    /// Return the platform-native window handle for screenshot capture.
19    /// Windows: `HWND`, macOS: `CGWindowID` (window number), Linux: `X11` window ID.
20    ///
21    /// # Errors
22    ///
23    /// Returns an error string if no matching window is found or the handle type is unsupported.
24    fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String>;
25    /// Perform a window management action (minimize, maximize, close, show, hide, etc.).
26    ///
27    /// # Errors
28    ///
29    /// Returns an error string if no matching window is found or the action fails.
30    fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String>;
31    /// Set the logical size of a window in device-independent pixels.
32    ///
33    /// # Errors
34    ///
35    /// Returns an error string if no matching window is found or the resize fails.
36    fn resize_window(&self, label: Option<&str>, width: u32, height: u32) -> Result<(), String>;
37    /// Set the logical position of a window in device-independent pixels.
38    ///
39    /// # Errors
40    ///
41    /// Returns an error string if no matching window is found or the move fails.
42    fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String>;
43    /// Set the title bar text of a window.
44    ///
45    /// # Errors
46    ///
47    /// Returns an error string if no matching window is found or the title change fails.
48    fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String>;
49
50    // ── Native (OS-level, trusted) input ───────────────────────────────────
51    //
52    // These deliver real OS input events (`isTrusted: true`), unlike the JS
53    // bridge's synthetic events. They are needed for app handlers that gate on
54    // `event.isTrusted` and for user-activation-gated browser APIs. Default
55    // implementations return an error so platforms without support degrade
56    // gracefully (callers fall back to synthetic input).
57
58    /// Type Unicode text as trusted OS keyboard input into the focused element
59    /// of the target window. The element must already hold focus.
60    ///
61    /// # Errors
62    /// Returns an error if not supported on this platform or the window is missing.
63    fn native_type_text(&self, _label: Option<&str>, _text: &str) -> Result<(), String> {
64        Err(
65            "native (trusted) keyboard input is not implemented on this platform; \
66             use synthetic input via the `input` tool without `trusted`"
67                .to_string(),
68        )
69    }
70
71    /// Press a single named key (e.g. `Enter`, `Tab`, `Escape`, `ArrowDown`) as
72    /// trusted OS keyboard input to the focused element of the target window.
73    ///
74    /// # Errors
75    /// Returns an error if not supported on this platform or the key is unknown.
76    fn native_key(&self, _label: Option<&str>, _key: &str) -> Result<(), String> {
77        Err(
78            "native (trusted) key input is not implemented on this platform; \
79             use synthetic input via the `input` tool without `trusted`"
80                .to_string(),
81        )
82    }
83
84    /// Click at logical (CSS-pixel) coordinates within the target window's
85    /// content area, as a trusted OS mouse event.
86    ///
87    /// # Errors
88    /// Returns an error if not supported on this platform or the window is missing.
89    fn native_click(&self, _label: Option<&str>, _x: f64, _y: f64) -> Result<(), String> {
90        Err(
91            "native (trusted) mouse input is not implemented on this platform; \
92             use synthetic input via the `interact` tool"
93                .to_string(),
94        )
95    }
96
97    // ── Backend Access ─────────────────────────────────────────────────────
98
99    /// Return the app's per-user data directory (e.g. `~/.local/share/<app>/`).
100    ///
101    /// # Errors
102    ///
103    /// Returns an error if the path cannot be resolved.
104    fn app_data_dir(&self) -> Result<std::path::PathBuf, String> {
105        Err("backend access not available".to_string())
106    }
107
108    /// Return the app's per-user config directory (e.g. `~/.config/<app>/`).
109    ///
110    /// # Errors
111    ///
112    /// Returns an error if the path cannot be resolved.
113    fn app_config_dir(&self) -> Result<std::path::PathBuf, String> {
114        Err("backend access not available".to_string())
115    }
116
117    /// Return the app's log directory.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if the path cannot be resolved.
122    fn app_log_dir(&self) -> Result<std::path::PathBuf, String> {
123        Err("backend access not available".to_string())
124    }
125
126    /// Return the app's local data directory.
127    ///
128    /// # Errors
129    ///
130    /// Returns an error if the path cannot be resolved.
131    fn app_local_data_dir(&self) -> Result<std::path::PathBuf, String> {
132        Err("backend access not available".to_string())
133    }
134
135    /// Return the Tauri app configuration as JSON.
136    #[must_use]
137    fn tauri_config(&self) -> serde_json::Value {
138        serde_json::Value::Null
139    }
140}
141
142fn find_window<'a, R: Runtime>(
143    windows: &'a std::collections::HashMap<String, tauri::WebviewWindow<R>>,
144    label: Option<&str>,
145) -> Result<&'a tauri::WebviewWindow<R>, String> {
146    match label {
147        Some(l) => windows
148            .get(l)
149            .ok_or_else(|| format!("window not found: {l}")),
150        None => windows
151            .get("main")
152            .or_else(|| windows.values().find(|w| w.is_visible().unwrap_or(false)))
153            .or_else(|| windows.values().next())
154            .ok_or_else(|| "no window available".to_string()),
155    }
156}
157
158/// Run `f` on the Tauri **main (UI) thread** and return its result.
159///
160/// Every webview/window access MUST happen on the main thread. Tauri's window/webview
161/// handles wrap a non-`Send` `Rc<WebView>` (and a `RefCell`-backed window store) that are
162/// guarded only by an `unsafe impl Send` with a *main-thread-only* contract. The Victauri
163/// MCP server runs on a background (axum/tokio) thread, so touching those handles directly —
164/// e.g. `self.webview_windows()` cloning the `Rc` — races the main thread's own refcounting
165/// (notably `tauri::ipc::protocol::get` while the app handles its real IPC). Two threads
166/// mutating a non-atomic `Rc` count corrupts it → use-after-free, which surfaces as
167/// `STATUS_*_BUFFER_OVERRUN` once Rust's debug `assert_unchecked` on `Rc::inc_strong`
168/// (1.78+) starts checking it. See Tauri issue #10001 for the identical crash class.
169///
170/// `run_on_main_thread` marshals the closure onto the UI thread (and runs it inline if we are
171/// already on it), so all `Rc` access stays single-threaded. The closure's value comes back
172/// over a oneshot `std::sync::mpsc` channel; the bounded `recv` is a safety net against a
173/// wedged event loop and never blocks the main thread (the closure runs *there*, the wait
174/// happens on the calling background thread).
175///
176/// The closure runs ON the UI/event-loop thread, so a panic in it would unwind into tao/winit
177/// and **abort the whole process** — strictly worse than the use-after-free this dispatcher
178/// exists to prevent — and would skip the result send, hanging the caller the full timeout. So
179/// the closure is run under `catch_unwind` and a panic is converted into an error that is always
180/// sent back. (Today every closure is panic-free by construction, but the helper must not let a
181/// future one take the app down.)
182fn on_main<R, T, F>(app: &tauri::AppHandle<R>, what: &str, f: F) -> Result<T, String>
183where
184    R: Runtime,
185    T: Send + 'static,
186    F: FnOnce(&tauri::AppHandle<R>) -> T + Send + 'static,
187{
188    use std::sync::atomic::{AtomicBool, Ordering};
189    let (tx, rx) = std::sync::mpsc::channel();
190    let app_for_closure = app.clone();
191    // If the caller times out and gives up, this flag tells the still-queued closure to skip
192    // its work — so a state-mutating op (resize/move/close/set_title) cannot apply long after
193    // the caller already saw a timeout error and moved on (a "spontaneous" window change).
194    let abandoned = std::sync::Arc::new(AtomicBool::new(false));
195    let abandoned_closure = abandoned.clone();
196    app.run_on_main_thread(move || {
197        if abandoned_closure.load(Ordering::Acquire) {
198            return;
199        }
200        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&app_for_closure)));
201        // Send only fails if the caller already timed out and dropped the receiver — ignore.
202        let _ = tx.send(result);
203    })
204    .map_err(|e| format!("failed to dispatch {what} to the main thread: {e}"))?;
205    // Blocking on the reply must not park a tokio runtime worker — under a wedged UI that could
206    // otherwise starve the embedded axum/MCP server. On a multi-threaded runtime, `block_in_place`
207    // tells the scheduler to run other tasks elsewhere while this thread blocks. (It panics on a
208    // current-thread runtime, so guard on the flavor; with no runtime at all, just block.)
209    let timeout = std::time::Duration::from_secs(10);
210    let received = match tokio::runtime::Handle::try_current().map(|h| h.runtime_flavor()) {
211        Ok(tokio::runtime::RuntimeFlavor::MultiThread) => {
212            tokio::task::block_in_place(|| rx.recv_timeout(timeout))
213        }
214        _ => rx.recv_timeout(timeout),
215    };
216    match received {
217        Ok(Ok(value)) => Ok(value),
218        Ok(Err(_panic)) => Err(format!("{what} panicked on the main thread")),
219        Err(e) => {
220            abandoned.store(true, Ordering::Release);
221            Err(format!("{what} did not complete on the main thread: {e}"))
222        }
223    }
224}
225
226impl<R: Runtime> WebviewBridge for tauri::AppHandle<R> {
227    fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
228        let label = label.map(str::to_string);
229        let script = script.to_string();
230        on_main(self, "eval_webview", move |app| {
231            let windows = app.webview_windows();
232            let webview = find_window(&windows, label.as_deref())?;
233            webview.eval(&script).map_err(|e| e.to_string())
234        })?
235    }
236
237    fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
238        let label = label.map(str::to_string);
239        on_main(self, "get_window_states", move |app| {
240            let windows = app.webview_windows();
241            let mut states = Vec::new();
242
243            for (win_label, window) in &windows {
244                if let Some(filter) = label.as_deref()
245                    && win_label != filter
246                {
247                    continue;
248                }
249
250                let pos = window.outer_position().unwrap_or_default();
251                let size = window.inner_size().unwrap_or_default();
252
253                states.push(WindowState {
254                    label: win_label.clone(),
255                    title: window.title().unwrap_or_default(),
256                    url: window.url().map(|u| u.to_string()).unwrap_or_default(),
257                    visible: window.is_visible().unwrap_or(false),
258                    focused: window.is_focused().unwrap_or(false),
259                    maximized: window.is_maximized().unwrap_or(false),
260                    minimized: window.is_minimized().unwrap_or(false),
261                    fullscreen: window.is_fullscreen().unwrap_or(false),
262                    position: (pos.x, pos.y),
263                    size: (size.width, size.height),
264                });
265            }
266
267            states
268        })
269        // An empty Vec here means the main-thread dispatch failed/timed out (a wedged UI), not
270        // "no windows" — log it so that case is diagnosable rather than silently indistinguishable.
271        .unwrap_or_else(|e| {
272            tracing::warn!("get_window_states: {e}");
273            Vec::new()
274        })
275    }
276
277    fn list_window_labels(&self) -> Vec<String> {
278        on_main(self, "list_window_labels", |app| {
279            app.webview_windows().keys().cloned().collect()
280        })
281        .unwrap_or_else(|e| {
282            tracing::warn!("list_window_labels: {e}");
283            Vec::new()
284        })
285    }
286
287    fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
288        let label = label.map(str::to_string);
289        on_main(self, "get_native_handle", move |app| {
290            use raw_window_handle::{HasWindowHandle, RawWindowHandle};
291
292            let windows = app.webview_windows();
293            let _webview = find_window(&windows, label.as_deref())?;
294            let handle = _webview.window_handle().map_err(|e| e.to_string())?;
295            match handle.as_raw() {
296                #[cfg(windows)]
297                RawWindowHandle::Win32(h) => Ok(h.hwnd.get()),
298                #[cfg(target_os = "macos")]
299                RawWindowHandle::AppKit(h) => {
300                    // CGWindowListCreateImage needs CGWindowID (the window number),
301                    // not the NSView pointer. Extract via Objective-C runtime.
302                    macos_window_number(h.ns_view.as_ptr())
303                }
304                #[cfg(target_os = "linux")]
305                RawWindowHandle::Xlib(h) => Ok(h.window as isize),
306                #[cfg(target_os = "linux")]
307                RawWindowHandle::Xcb(h) => Ok(h.window.get() as isize),
308                _ => Err("unsupported window handle type on this platform".to_string()),
309            }
310        })?
311    }
312
313    #[cfg(windows)]
314    fn native_type_text(&self, label: Option<&str>, text: &str) -> Result<(), String> {
315        let hwnd = self.get_native_handle(label)?;
316        win_focus(hwnd);
317        win_send_text(text)
318    }
319
320    #[cfg(windows)]
321    fn native_key(&self, label: Option<&str>, key: &str) -> Result<(), String> {
322        let hwnd = self.get_native_handle(label)?;
323        win_focus(hwnd);
324        win_send_key(key)
325    }
326
327    #[cfg(windows)]
328    fn native_click(&self, label: Option<&str>, x: f64, y: f64) -> Result<(), String> {
329        let hwnd = self.get_native_handle(label)?;
330        win_focus(hwnd);
331        win_click(hwnd, x, y)
332    }
333
334    fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
335        let label = label.map(str::to_string);
336        let action = action.to_string();
337        on_main(self, "manage_window", move |app| {
338            let windows = app.webview_windows();
339            let window = find_window(&windows, label.as_deref())?;
340
341            match action.as_str() {
342                "minimize" => window.minimize().map_err(|e| e.to_string())?,
343                "unminimize" => window.unminimize().map_err(|e| e.to_string())?,
344                "maximize" => window.maximize().map_err(|e| e.to_string())?,
345                "unmaximize" => window.unmaximize().map_err(|e| e.to_string())?,
346                "close" => window.close().map_err(|e| e.to_string())?,
347                "focus" => window.set_focus().map_err(|e| e.to_string())?,
348                "show" => window.show().map_err(|e| e.to_string())?,
349                "hide" => window.hide().map_err(|e| e.to_string())?,
350                "fullscreen" => window.set_fullscreen(true).map_err(|e| e.to_string())?,
351                "unfullscreen" => window.set_fullscreen(false).map_err(|e| e.to_string())?,
352                "always_on_top" => window.set_always_on_top(true).map_err(|e| e.to_string())?,
353                "not_always_on_top" => {
354                    window.set_always_on_top(false).map_err(|e| e.to_string())?;
355                }
356                _ => return Err(format!("unknown action: {action}")),
357            }
358
359            Ok(format!("{action} executed"))
360        })?
361    }
362
363    fn resize_window(&self, label: Option<&str>, width: u32, height: u32) -> Result<(), String> {
364        let label = label.map(str::to_string);
365        on_main(self, "resize_window", move |app| {
366            let windows = app.webview_windows();
367            let window = find_window(&windows, label.as_deref())?;
368
369            window
370                .set_size(tauri::LogicalSize::new(width, height))
371                .map_err(|e| e.to_string())
372        })?
373    }
374
375    fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
376        let label = label.map(str::to_string);
377        on_main(self, "move_window", move |app| {
378            let windows = app.webview_windows();
379            let window = find_window(&windows, label.as_deref())?;
380
381            window
382                .set_position(tauri::LogicalPosition::new(x, y))
383                .map_err(|e| e.to_string())
384        })?
385    }
386
387    fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
388        let label = label.map(str::to_string);
389        let title = title.to_string();
390        on_main(self, "set_window_title", move |app| {
391            let windows = app.webview_windows();
392            let window = find_window(&windows, label.as_deref())?;
393
394            window.set_title(&title).map_err(|e| e.to_string())
395        })?
396    }
397
398    fn app_data_dir(&self) -> Result<std::path::PathBuf, String> {
399        self.path().app_data_dir().map_err(|e| e.to_string())
400    }
401
402    fn app_config_dir(&self) -> Result<std::path::PathBuf, String> {
403        self.path().app_config_dir().map_err(|e| e.to_string())
404    }
405
406    fn app_log_dir(&self) -> Result<std::path::PathBuf, String> {
407        self.path().app_log_dir().map_err(|e| e.to_string())
408    }
409
410    fn app_local_data_dir(&self) -> Result<std::path::PathBuf, String> {
411        self.path().app_local_data_dir().map_err(|e| e.to_string())
412    }
413
414    fn tauri_config(&self) -> serde_json::Value {
415        let config = self.config();
416
417        let windows: Vec<serde_json::Value> = config
418            .app
419            .windows
420            .iter()
421            .map(|w| {
422                serde_json::json!({
423                    "label": w.label,
424                    "title": w.title,
425                    "url": format!("{}", w.url),
426                    "width": w.width,
427                    "height": w.height,
428                    "visible": w.visible,
429                    "resizable": w.resizable,
430                    "fullscreen": w.fullscreen,
431                    "decorations": w.decorations,
432                    "transparent": w.transparent,
433                    "always_on_top": w.always_on_top,
434                })
435            })
436            .collect();
437
438        let plugins: Vec<String> = config.plugins.0.keys().cloned().collect();
439
440        let security = serde_json::json!({
441            "csp": config.app.security.csp.as_ref().map(|c| format!("{c}")),
442            "freeze_prototype": config.app.security.freeze_prototype,
443            "capabilities": config.app.security.capabilities.iter().map(|c| {
444                match c {
445                    tauri::utils::config::CapabilityEntry::Inlined(cap) => {
446                        serde_json::json!({
447                            "identifier": cap.identifier,
448                            "description": cap.description,
449                            "windows": cap.windows,
450                            "webviews": cap.webviews,
451                            "permissions": cap.permissions.iter().map(|p| format!("{p:?}")).collect::<Vec<_>>(),
452                            "platforms": cap.platforms,
453                        })
454                    }
455                    tauri::utils::config::CapabilityEntry::Reference(path) => {
456                        serde_json::json!({ "reference": path })
457                    }
458                }
459            }).collect::<Vec<_>>(),
460        });
461
462        serde_json::json!({
463            "identifier": config.identifier,
464            "product_name": config.product_name,
465            "version": config.version,
466            "windows": windows,
467            "plugins": plugins,
468            "security": security,
469        })
470    }
471}
472
473#[cfg(target_os = "macos")]
474#[allow(unsafe_code)]
475fn macos_window_number(ns_view: *mut std::ffi::c_void) -> Result<isize, String> {
476    unsafe extern "C" {
477        fn objc_msgSend(obj: *mut std::ffi::c_void, sel: *mut std::ffi::c_void) -> isize;
478        fn sel_registerName(name: *const std::ffi::c_char) -> *mut std::ffi::c_void;
479    }
480
481    if ns_view.is_null() {
482        return Err("null NSView handle".to_string());
483    }
484
485    // SAFETY: `ns_view` is a valid NSView pointer obtained from Tauri's
486    // `with_webview` callback; null was checked above. `objc_msgSend` and
487    // `sel_registerName` are stable Objective-C runtime ABI.
488    unsafe {
489        let sel_window = sel_registerName(c"window".as_ptr());
490        let ns_window = objc_msgSend(ns_view, sel_window);
491        if ns_window == 0 {
492            return Err("NSView has no parent NSWindow".to_string());
493        }
494        let sel_window_number = sel_registerName(c"windowNumber".as_ptr());
495        let ns_window_ptr = ns_window as *mut std::ffi::c_void;
496        let window_number = objc_msgSend(ns_window_ptr, sel_window_number);
497        if window_number <= 0 {
498            return Err(format!("invalid CGWindowID: {window_number}"));
499        }
500        Ok(window_number)
501    }
502}
503
504// ── Windows native (trusted) input helpers ─────────────────────────────────
505//
506// These deliver real OS input via SendInput, producing events with
507// `isTrusted: true` (unlike the JS bridge's synthetic events).
508
509#[cfg(windows)]
510fn win_hwnd(hwnd: isize) -> windows::Win32::Foundation::HWND {
511    windows::Win32::Foundation::HWND(hwnd as *mut core::ffi::c_void)
512}
513
514/// Bring the target window to the foreground so input is routed to it, then
515/// give the OS a brief moment to apply focus.
516#[allow(unsafe_code)]
517#[cfg(windows)]
518fn win_focus(hwnd: isize) {
519    use windows::Win32::UI::WindowsAndMessaging::SetForegroundWindow;
520    // SAFETY: hwnd comes from Tauri's window handle; SetForegroundWindow is safe
521    // to call with any HWND (returns false if it fails).
522    unsafe {
523        let _ = SetForegroundWindow(win_hwnd(hwnd));
524    }
525    std::thread::sleep(std::time::Duration::from_millis(40));
526}
527
528#[cfg(windows)]
529fn win_keyboard_input(
530    vk: u16,
531    scan: u16,
532    key_up: bool,
533    unicode: bool,
534) -> windows::Win32::UI::Input::KeyboardAndMouse::INPUT {
535    use windows::Win32::UI::Input::KeyboardAndMouse::{
536        INPUT, INPUT_0, INPUT_KEYBOARD, KEYBD_EVENT_FLAGS, KEYBDINPUT, KEYEVENTF_KEYUP,
537        KEYEVENTF_UNICODE, VIRTUAL_KEY,
538    };
539    let mut flags = KEYBD_EVENT_FLAGS(0);
540    if unicode {
541        flags |= KEYEVENTF_UNICODE;
542    }
543    if key_up {
544        flags |= KEYEVENTF_KEYUP;
545    }
546    INPUT {
547        r#type: INPUT_KEYBOARD,
548        Anonymous: INPUT_0 {
549            ki: KEYBDINPUT {
550                wVk: VIRTUAL_KEY(vk),
551                wScan: scan,
552                dwFlags: flags,
553                time: 0,
554                dwExtraInfo: 0,
555            },
556        },
557    }
558}
559
560/// Type Unicode text via `SendInput` (`KEYEVENTF_UNICODE` per UTF-16 code unit).
561#[allow(unsafe_code)]
562#[cfg(windows)]
563fn win_send_text(text: &str) -> Result<(), String> {
564    use windows::Win32::UI::Input::KeyboardAndMouse::{INPUT, SendInput};
565    let mut inputs: Vec<INPUT> = Vec::new();
566    for unit in text.encode_utf16() {
567        inputs.push(win_keyboard_input(0, unit, false, true));
568        inputs.push(win_keyboard_input(0, unit, true, true));
569    }
570    if inputs.is_empty() {
571        return Ok(());
572    }
573    let cb = i32::try_from(std::mem::size_of::<INPUT>()).unwrap_or(0);
574    // SAFETY: `inputs` is a valid slice of properly-initialized INPUT structs.
575    let sent = unsafe { SendInput(&inputs, cb) } as usize;
576    if sent == inputs.len() {
577        Ok(())
578    } else {
579        Err(format!(
580            "SendInput delivered {sent}/{} key events",
581            inputs.len()
582        ))
583    }
584}
585
586/// Map a named key (Playwright-style) to a Win32 virtual-key code.
587#[cfg(windows)]
588fn win_vk_for_key(key: &str) -> Option<u16> {
589    use windows::Win32::UI::Input::KeyboardAndMouse as k;
590    let vk = match key {
591        "Enter" | "Return" => k::VK_RETURN,
592        "Tab" => k::VK_TAB,
593        "Escape" | "Esc" => k::VK_ESCAPE,
594        "Backspace" => k::VK_BACK,
595        "Delete" | "Del" => k::VK_DELETE,
596        "ArrowUp" | "Up" => k::VK_UP,
597        "ArrowDown" | "Down" => k::VK_DOWN,
598        "ArrowLeft" | "Left" => k::VK_LEFT,
599        "ArrowRight" | "Right" => k::VK_RIGHT,
600        "Home" => k::VK_HOME,
601        "End" => k::VK_END,
602        "PageUp" => k::VK_PRIOR,
603        "PageDown" => k::VK_NEXT,
604        "Space" | " " => k::VK_SPACE,
605        "F1" => k::VK_F1,
606        "F2" => k::VK_F2,
607        "F3" => k::VK_F3,
608        "F4" => k::VK_F4,
609        "F5" => k::VK_F5,
610        "F6" => k::VK_F6,
611        "F7" => k::VK_F7,
612        "F8" => k::VK_F8,
613        "F9" => k::VK_F9,
614        "F10" => k::VK_F10,
615        "F11" => k::VK_F11,
616        "F12" => k::VK_F12,
617        _ => return None,
618    };
619    Some(vk.0)
620}
621
622/// Press and release a named key, or a single printable character, via `SendInput`.
623#[allow(unsafe_code)]
624#[cfg(windows)]
625fn win_send_key(key: &str) -> Result<(), String> {
626    use windows::Win32::UI::Input::KeyboardAndMouse::{INPUT, SendInput};
627    let inputs: Vec<INPUT> = if let Some(vk) = win_vk_for_key(key) {
628        vec![
629            win_keyboard_input(vk, 0, false, false),
630            win_keyboard_input(vk, 0, true, false),
631        ]
632    } else {
633        // Single printable character → send as Unicode.
634        let mut chars = key.chars();
635        let (Some(c), None) = (chars.next(), chars.next()) else {
636            return Err(format!(
637                "unknown key '{key}' (use a named key or a single character)"
638            ));
639        };
640        let mut buf = [0u16; 2];
641        let mut v = Vec::new();
642        for unit in c.encode_utf16(&mut buf) {
643            v.push(win_keyboard_input(0, *unit, false, true));
644            v.push(win_keyboard_input(0, *unit, true, true));
645        }
646        v
647    };
648    let cb = i32::try_from(std::mem::size_of::<INPUT>()).unwrap_or(0);
649    // SAFETY: valid slice of initialized INPUT structs.
650    let sent = unsafe { SendInput(&inputs, cb) } as usize;
651    if sent == inputs.len() {
652        Ok(())
653    } else {
654        Err(format!(
655            "SendInput delivered {sent}/{} key events",
656            inputs.len()
657        ))
658    }
659}
660
661/// Click at logical (CSS-pixel) coordinates within the window's content area
662/// via an absolute-positioned `SendInput` mouse sequence (move + down + up).
663#[allow(unsafe_code)]
664#[cfg(windows)]
665fn win_click(hwnd: isize, x: f64, y: f64) -> Result<(), String> {
666    use windows::Win32::Foundation::POINT;
667    use windows::Win32::Graphics::Gdi::ClientToScreen;
668    use windows::Win32::UI::HiDpi::GetDpiForWindow;
669    use windows::Win32::UI::Input::KeyboardAndMouse::{
670        INPUT, INPUT_0, INPUT_MOUSE, MOUSE_EVENT_FLAGS, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_LEFTDOWN,
671        MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_VIRTUALDESK, MOUSEINPUT, SendInput,
672    };
673    use windows::Win32::UI::WindowsAndMessaging::{
674        GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
675        SM_YVIRTUALSCREEN,
676    };
677    let h = win_hwnd(hwnd);
678    // SAFETY: GetDpiForWindow/GetSystemMetrics/ClientToScreen are safe to call
679    // with a valid HWND; ClientToScreen writes into our stack POINT.
680    let (nx, ny) = unsafe {
681        let dpi = GetDpiForWindow(h);
682        let scale = if dpi == 0 { 1.0 } else { f64::from(dpi) / 96.0 };
683        let mut pt = POINT {
684            x: (x * scale) as i32,
685            y: (y * scale) as i32,
686        };
687        let _ = ClientToScreen(h, &mut pt);
688        let vx = GetSystemMetrics(SM_XVIRTUALSCREEN);
689        let vy = GetSystemMetrics(SM_YVIRTUALSCREEN);
690        let vw = GetSystemMetrics(SM_CXVIRTUALSCREEN);
691        let vh = GetSystemMetrics(SM_CYVIRTUALSCREEN);
692        if vw <= 1 || vh <= 1 {
693            return Err("virtual screen metrics unavailable".to_string());
694        }
695        let nx = ((f64::from(pt.x - vx)) * 65535.0 / f64::from(vw - 1)) as i32;
696        let ny = ((f64::from(pt.y - vy)) * 65535.0 / f64::from(vh - 1)) as i32;
697        (nx, ny)
698    };
699    let make = |flags: MOUSE_EVENT_FLAGS| INPUT {
700        r#type: INPUT_MOUSE,
701        Anonymous: INPUT_0 {
702            mi: MOUSEINPUT {
703                dx: nx,
704                dy: ny,
705                mouseData: 0,
706                dwFlags: flags,
707                time: 0,
708                dwExtraInfo: 0,
709            },
710        },
711    };
712    let base = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK;
713    let inputs = [
714        make(base | MOUSEEVENTF_MOVE),
715        make(base | MOUSEEVENTF_LEFTDOWN),
716        make(base | MOUSEEVENTF_LEFTUP),
717    ];
718    let cb = i32::try_from(std::mem::size_of::<INPUT>()).unwrap_or(0);
719    // SAFETY: valid slice of initialized INPUT structs.
720    let sent = unsafe { SendInput(&inputs, cb) } as usize;
721    if sent == inputs.len() {
722        Ok(())
723    } else {
724        Err(format!(
725            "SendInput delivered {sent}/{} mouse events",
726            inputs.len()
727        ))
728    }
729}