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.
7///
8/// Victauri implements this for `tauri::AppHandle`; it is public so tests (and embedders) can
9/// supply a mock. **Stability contract:** any method added to this trait in a future release
10/// will have a default implementation, so implementing it does not pin you to an exact
11/// version. [`WindowState`] is `#[non_exhaustive]` — build one with
12/// `WindowState::new(label).with_*(..)`.
13pub trait WebviewBridge: Send + Sync {
14    /// Execute JavaScript in the target webview (defaults to "main" or first visible window).
15    ///
16    /// # Errors
17    ///
18    /// Returns an error string if no matching window is found or the eval fails.
19    fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String>;
20    /// Retrieve the state of one or all windows (position, size, visibility, focus, URL).
21    fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState>;
22    /// Like [`get_window_states`](Self::get_window_states), but distinguishes "could not ask"
23    /// (the UI thread did not answer in time) from "no such window". A caller that reports a
24    /// window as missing, or a list as empty, must use this.
25    ///
26    /// # Errors
27    ///
28    /// Returns an error string if the window states could not be obtained.
29    fn try_get_window_states(&self, label: Option<&str>) -> Result<Vec<WindowState>, String> {
30        Ok(self.get_window_states(label))
31    }
32    /// Return the labels of all open webview windows.
33    fn list_window_labels(&self) -> Vec<String>;
34    /// Like [`list_window_labels`](Self::list_window_labels), but distinguishes "could not
35    /// ask" (e.g. the UI thread did not answer in time) from "there are no windows". A caller
36    /// deciding that a window is GONE must use this — an empty list from a wedged UI is not
37    /// evidence of anything.
38    ///
39    /// # Errors
40    ///
41    /// Returns an error string if the window list could not be obtained.
42    fn try_list_window_labels(&self) -> Result<Vec<String>, String> {
43        Ok(self.list_window_labels())
44    }
45    /// Like [`eval_webview`](Self::eval_webview), but returns the label of the window the
46    /// script was actually delivered to (for `None`, the resolved default window). The default
47    /// implementation returns the requested label, or an EMPTY string when it cannot know which
48    /// window a `None` label resolved to — callers must treat empty as "unknown".
49    ///
50    /// # Errors
51    ///
52    /// Returns an error string if no matching window is found or the eval fails.
53    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    /// Return the platform-native window handle for screenshot capture.
58    /// Windows: `HWND`, macOS: `CGWindowID` (window number), Linux: `X11` window ID.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error string if no matching window is found or the handle type is unsupported.
63    fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String>;
64    /// Perform a window management action (minimize, maximize, close, show, hide, etc.).
65    ///
66    /// # Errors
67    ///
68    /// Returns an error string if no matching window is found or the action fails.
69    fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String>;
70    /// Set the logical size of a window in device-independent pixels.
71    ///
72    /// # Errors
73    ///
74    /// Returns an error string if no matching window is found or the resize fails.
75    fn resize_window(&self, label: Option<&str>, width: u32, height: u32) -> Result<(), String>;
76    /// Set the logical position of a window in device-independent pixels.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error string if no matching window is found or the move fails.
81    fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String>;
82    /// Set the title bar text of a window.
83    ///
84    /// # Errors
85    ///
86    /// Returns an error string if no matching window is found or the title change fails.
87    fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String>;
88
89    // ── Native (OS-level, trusted) input ───────────────────────────────────
90    //
91    // These deliver real OS input events (`isTrusted: true`), unlike the JS
92    // bridge's synthetic events. They are needed for app handlers that gate on
93    // `event.isTrusted` and for user-activation-gated browser APIs. Default
94    // implementations return an error so platforms without support degrade
95    // gracefully (callers fall back to synthetic input).
96
97    /// Type Unicode text as trusted OS keyboard input into the focused element
98    /// of the target window. The element must already hold focus.
99    ///
100    /// # Errors
101    /// Returns an error if not supported on this platform or the window is missing.
102    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    /// Press a single named key (e.g. `Enter`, `Tab`, `Escape`, `ArrowDown`) as
111    /// trusted OS keyboard input to the focused element of the target window.
112    ///
113    /// # Errors
114    /// Returns an error if not supported on this platform or the key is unknown.
115    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    /// Click at logical (CSS-pixel) coordinates within the target window's
124    /// content area, as a trusted OS mouse event.
125    ///
126    /// # Errors
127    /// Returns an error if not supported on this platform or the window is missing.
128    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    // ── Backend Access ─────────────────────────────────────────────────────
137
138    /// Return the app's per-user data directory (e.g. `~/.local/share/<app>/`).
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if the path cannot be resolved.
143    fn app_data_dir(&self) -> Result<std::path::PathBuf, String> {
144        Err("backend access not available".to_string())
145    }
146
147    /// Return the app's per-user config directory (e.g. `~/.config/<app>/`).
148    ///
149    /// # Errors
150    ///
151    /// Returns an error if the path cannot be resolved.
152    fn app_config_dir(&self) -> Result<std::path::PathBuf, String> {
153        Err("backend access not available".to_string())
154    }
155
156    /// Return the app's log directory.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if the path cannot be resolved.
161    fn app_log_dir(&self) -> Result<std::path::PathBuf, String> {
162        Err("backend access not available".to_string())
163    }
164
165    /// Return the app's local data directory.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error if the path cannot be resolved.
170    fn app_local_data_dir(&self) -> Result<std::path::PathBuf, String> {
171        Err("backend access not available".to_string())
172    }
173
174    /// Return the Tauri app configuration as JSON.
175    #[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            // Deterministic fallbacks: `webview_windows()` is a HashMap whose iteration order
192            // differs between calls, so "first visible" must be chosen by a stable key or two
193            // consecutive calls can pick different windows.
194            .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
207/// The label of the window a `None` label resolves to (see `find_window`). MAIN THREAD ONLY:
208/// it lists the webview windows, which clones their handles (see [`on_main`]) — for a sync
209/// Tauri command such as the eval callback.
210pub(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
217/// Serializes Victauri's main-thread round trips.
218///
219/// **Correction (0.9.0): the heap corruption described below was NOT caused by concurrent round
220/// trips as such.** Its real cause was the `AppHandle` clone each round trip made on the calling
221/// thread (see [`on_main`]) — cloning or dropping any Tauri handle off the main thread races a
222/// non-atomic `Rc` inside tao on Linux. Serializing only limited how many threads cloned at once,
223/// which is why it reduced the crash without removing it. The lock stays: it bounds how much
224/// Victauri can queue onto the UI thread and is measured-safe. The original 0.8.8 notes follow.
225///
226/// Concurrent `run_on_main_thread` round trips corrupt the process heap on Linux/WebKitGTK —
227/// glibc aborts the app with `malloc(): unaligned tcache chunk detected` or `corrupted
228/// double-linked list`. Measured on Ubuntu 24.04 + `WebKitGTK` 2.52, three concurrent
229/// introspection loops for 8s: **5/8 runs died** unserialized, **8/8** with the tokio
230/// `block_in_place` hand-off removed, and **0/28** once the whole round trip is serialized here.
231///
232/// The bisect that pinned it down, so a future reader does not re-do it:
233/// * Not the reload. Reload-hammering with no introspection never died (0/4); introspection
234///   with no reload at all died every time (4/4). The crash is unrelated to page reloads.
235/// * Not the dispatch alone. Locking only around `run_on_main_thread` still died 4/8 — what
236///   matters is how many round trips are in flight at once, not how the message is posted.
237/// * Not load. One unthrottled loop issuing the same total number of calls never died (0/4);
238///   three concurrent loops died. Concurrency is the variable, not volume.
239/// * Not our HTTP/tokio layer. A tool that touches no webview (`get_memory_stats`) at the same
240///   concurrency never died (0/5).
241/// * Not new. The pre-rmcp-3.1.2 tree died at the same rate (6/8), so this long predates 0.8.8.
242///
243/// Serializing costs effectively nothing: the closures already execute one at a time on the
244/// single main thread, so this only stops several round trips being in flight *around* it.
245///
246/// The lock is only taken OFF the main thread: a caller already on the main thread (including
247/// an `on_main` closure that calls back into the bridge) runs inline without it — see `on_main`.
248static MAIN_DISPATCH_LOCK: DispatchGate = DispatchGate::new();
249
250/// The serialization state behind [`MAIN_DISPATCH_LOCK`] (a separate type so tests can use
251/// their own gate instead of the process-wide one).
252///
253/// The lock alone is not enough: a caller whose closure STARTED but outlived its timeout + grace
254/// returns "outcome unknown" and releases the lock while that closure is still executing on the
255/// main thread. `in_flight` counts dispatched closures that have reached the main thread and not
256/// yet finished, and the next lock holder also waits (within its own deadline) for it to reach
257/// zero — so there is still never more than one round trip in flight (R4-LOCK1).
258struct 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
272/// Decrements [`DispatchGate::in_flight`] when a dispatched closure finishes — including by
273/// unwinding, so a panicking closure can never wedge every later round trip.
274struct 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
284/// The Tauri main (UI) thread, recorded ON it by [`adopt_main_thread`] (from plugin `setup`).
285static MAIN_THREAD: std::sync::OnceLock<std::thread::ThreadId> = std::sync::OnceLock::new();
286
287/// Record the calling thread as the Tauri main thread. Called on the main thread by
288/// [`adopt_main_thread`].
289fn record_main_thread() {
290    let _ = MAIN_THREAD.set(std::thread::current().id());
291}
292
293thread_local! {
294    /// An app handle owned by the main thread and only ever cloned or dropped there (installed
295    /// by [`install_main_app`] via [`adopt_main_thread`]). `on_main` closures take their handle from
296    /// here instead of from a clone made on the calling thread — see [`on_main`] for why a
297    /// Tauri handle must never be cloned or dropped off the main thread.
298    static MAIN_APP: std::cell::RefCell<Option<Box<dyn std::any::Any>>> =
299        const { std::cell::RefCell::new(None) };
300}
301
302/// Keep a main-thread-owned clone of `app` for `on_main` closures. Must be called ON the main
303/// thread (see [`adopt_main_thread`]); a call from any other thread stores into that thread's
304/// slot, which no closure ever reads.
305fn 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/// How plugin setup adopted the Tauri main thread (see [`adopt_main_thread`]).
310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub(crate) enum MainThreadAdoption {
312    /// Setup ran on the main thread: recorded and installed in place.
313    Inline,
314    /// Setup ran on another thread; recording and installation were posted to the main thread.
315    Posted,
316}
317
318/// Record the Tauri main thread and give it its own app handle ([`record_main_thread`] +
319/// [`install_main_app`]) — ON the main thread, wherever plugin `setup` happens to run.
320///
321/// Tauri normally runs plugin `setup` on the main thread, but an app may also register a plugin
322/// at runtime from a background thread (`AppHandle::plugin`), and `setup` then runs THERE.
323/// Recording that thread as "main" and installing the handle in its thread-local slot would
324/// leave every later [`on_main`] round trip failing with "the main-thread app handle is not
325/// installed" (the closures read the real main thread's slot, which stays empty).
326///
327/// Which thread is the main one is learned from Tauri itself: `run_on_main_thread` runs a
328/// closure inline when called ON the main thread and queues it otherwise, so a tiny probe tells
329/// the two apart. On the main thread the adoption happens in place (no handle is cloned
330/// anywhere else). Off it, the adoption is posted to the main thread with a handle cloned here —
331/// one unavoidable off-main clone, of the same kind `setup` itself already makes in that case
332/// (it clones the handle for the MCP server) — and round trips made before it runs fail cleanly
333/// with "not installed" instead of misbehaving.
334pub(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
347/// Generic core of [`adopt_main_thread`], testable with a fake main thread. `post` must run a
348/// job inline when called on the main thread and queue it there otherwise (Tauri's
349/// `run_on_main_thread` contract); `adopt` is always called on the main thread.
350fn 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    // Queued (off the main thread), this runs later on the main thread and finds a different
361    // thread id there, so it does nothing.
362    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
376/// The main thread's own app handle (a clone made HERE, on the main thread), if installed.
377fn 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
386/// Whether the current thread is the recorded Tauri main thread (false if not yet recorded).
387fn is_main_thread() -> bool {
388    MAIN_THREAD
389        .get()
390        .is_some_and(|id| *id == std::thread::current().id())
391}
392
393/// Run `f` on the Tauri **main (UI) thread** and return its result.
394///
395/// Every webview/window access MUST happen on the main thread. Tauri's window/webview
396/// handles wrap a non-`Send` `Rc<WebView>` (and a `RefCell`-backed window store) that are
397/// guarded only by an `unsafe impl Send` with a *main-thread-only* contract. The Victauri
398/// MCP server runs on a background (axum/tokio) thread, so touching those handles directly —
399/// e.g. `self.webview_windows()` cloning the `Rc` — races the main thread's own refcounting
400/// (notably `tauri::ipc::protocol::get` while the app handles its real IPC). Two threads
401/// mutating a non-atomic `Rc` count corrupts it → use-after-free, which surfaces as
402/// `STATUS_*_BUFFER_OVERRUN` once Rust's debug `assert_unchecked` on `Rc::inc_strong`
403/// (1.78+) starts checking it. See Tauri issue #10001 for the identical crash class.
404///
405/// **The same applies to the `AppHandle` itself — never clone or drop one off the main thread.**
406/// On Linux every Tauri handle (`AppHandle`, `Webview`, `WebviewWindow`) carries
407/// tauri-runtime-wry's `Context`, which holds the main-thread `DispatcherMainThreadContext` BY
408/// VALUE, which holds tao's `EventLoopWindowTarget`, which holds `windows:
409/// Rc<RefCell<HashSet<WindowId>>>`. So cloning or dropping any handle bumps a NON-ATOMIC count
410/// that the main thread bumps all the time (IPC, window access). This function used to clone
411/// the `AppHandle` on the calling thread for every round trip; under concurrent introspection
412/// that corrupted the heap (glibc `corrupted double-linked list` / `unaligned fastbin chunk`) on
413/// Linux/WebKitGTK. Proven without Victauri: a bare Tauri app whose background threads only
414/// clone+drop its `AppHandle` while the page makes IPC calls aborted 3/3; 12 threads doing
415/// `run_on_main_thread` with an off-thread clone aborted 5/5, and 5/5 with an EMPTY closure.
416/// So the closure takes a handle cloned ON the main thread ([`install_main_app`]), and the
417/// caller only borrows `app` to post it (`AppHandle::run_on_main_thread` does not clone).
418///
419/// `run_on_main_thread` marshals the closure onto the UI thread (and runs it inline if we are
420/// already on it), so all `Rc` access stays single-threaded. The closure's value comes back
421/// over a oneshot `std::sync::mpsc` channel; the bounded `recv` is a safety net against a
422/// wedged event loop and never blocks the main thread (the closure runs *there*, the wait
423/// happens on the calling background thread).
424///
425/// The closure runs ON the UI/event-loop thread, so a panic in it would unwind into tao/winit
426/// and **abort the whole process** — strictly worse than the use-after-free this dispatcher
427/// exists to prevent — and would skip the result send, hanging the caller the full timeout. So
428/// the closure is run under `catch_unwind` and a panic is converted into an error that is always
429/// sent back. (Today every closure is panic-free by construction, but the helper must not let a
430/// future one take the app down.)
431///
432/// Round trips are additionally serialized through [`MAIN_DISPATCH_LOCK`] — see there for the
433/// heap corruption that requires it and the bisect that established it.
434///
435/// **Blocking.** Off the main thread this call BLOCKS its thread until the closure's outcome is
436/// known — up to its 10 s deadline, or about 20 s when the closure started just before it (see
437/// [`serialized_round_trip`]). On a multi-threaded tokio runtime it runs under `block_in_place`,
438/// so the runtime keeps serving other tasks. On a **current-thread** runtime that is impossible
439/// (`block_in_place` panics there), so the whole runtime — every task on it — stalls for that
440/// long while the UI is wedged. Victauri's own server runs on a multi-threaded runtime; do not
441/// call bridge methods from a current-thread runtime you care about.
442fn 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    // Already ON the main thread (a sync Tauri command, a menu handler, or an `on_main` closure
451    // that calls back into the bridge): run inline, WITHOUT the dispatch lock. Taking the lock
452    // here could deadlock — a background holder waits for its closure, which is queued behind
453    // us on this very thread — freezing the UI for the full timeout. Skipping it is safe: no
454    // other closure can execute on this thread concurrently, and the heap corruption the lock
455    // prevents needs several cross-thread round trips in flight, which an inline call is not.
456    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        // No handle is cloned here, on the calling thread (see above): the closure takes the
463        // main thread's own handle, and `app` is only borrowed to post it.
464        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    // Blocking here must not park a tokio runtime worker — under a wedged UI that could
485    // otherwise starve the embedded axum/MCP server. On a multi-threaded runtime, `block_in_place`
486    // tells the scheduler to run other tasks elsewhere while this thread blocks. (It panics on a
487    // current-thread runtime, so guard on the flavor; with no runtime at all, just block.)
488    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
494/// One serialized round trip: take `gate`'s lock (see [`MAIN_DISPATCH_LOCK`]), then hand `f` to `post`
495/// and wait for it.
496///
497/// `timeout` is one deadline shared by everything BEFORE the closure starts — the wait for the
498/// lock, for an earlier abandoned closure still running, and for this closure to be picked up —
499/// so serializing cannot stack N callers into N * timeout when the UI wedges: a closure that has
500/// not started by the deadline is abandoned (it never runs) and the call returns at `timeout`.
501/// A closure that DID start before the deadline is waited for up to a further `timeout` (the
502/// `grace` passed to [`dispatch_and_wait`]) so its real outcome is reported rather than
503/// "timed out" for work that happened. So a call returns within `timeout` if its closure never
504/// started, and within about 2 × `timeout` in the worst case (it started just before the
505/// deadline and then ran long).
506fn 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    // An earlier caller may have given up on a closure that is STILL RUNNING on the main thread
538    // (it started, then outlived that caller's timeout + grace). Wait for it — bounded by this
539    // caller's own deadline, so callers never stack into N * timeout — before putting a second
540    // round trip in flight beside it.
541    // A plain sleep. (Round 4 briefly tried a yield spin here and removed it while chasing a
542    // Linux heap abort; the abort's real cause was the off-main-thread `AppHandle` clone
543    // documented on `on_main`, not the wait.)
544    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    // Count the closure from BEFORE it can start (the count is raised ahead of the
559    // queued->running transition inside `dispatch_and_wait`'s job), so once a caller has seen
560    // its job start, the next lock holder is guaranteed to see it in flight.
561    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    // Returns with the lock still held until the closure has either run or been abandoned,
570    // so no second round trip is ever in flight beside a closure that may still start.
571    dispatch_and_wait(what, remaining, timeout, counted_post, f)
572}
573
574/// A dispatched closure that has not started yet.
575const JOB_QUEUED: u8 = 0;
576/// A dispatched closure that has started (its effects will happen).
577const JOB_RUNNING: u8 = 1;
578/// A dispatched closure its caller gave up on before it started: it must never run.
579const JOB_ABANDONED: u8 = 2;
580
581/// Hand `f` to `post` (which queues it on the main thread) and wait up to `remaining` for it.
582///
583/// The closure and a caller that times out race through ONE compare-and-swap: the closure only
584/// runs if it moves Queued -> Running first, and the caller only reports a timeout if it moves
585/// Queued -> Abandoned first. So a timeout error always means the work never happened and never
586/// will — a state-mutating op (a resize, move, close, title change or eval) cannot apply after
587/// the caller already saw the error and moved on. A closure that started just before the
588/// deadline is waited for (up to `grace`) and its real outcome returned, instead of "timed out"
589/// for work that ran.
590fn 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        // Send only fails if the caller already gave up and dropped the receiver — ignore.
616        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            // It started before we gave up: its effects will happen, so report its outcome.
642            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        // An empty Vec here means the main-thread dispatch failed/timed out (a wedged UI), not
666        // "no windows" — log it so that case is diagnosable rather than silently indistinguishable.
667        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                    // CGWindowListCreateImage needs CGWindowID (the window number),
745                    // not the NSView pointer. Extract via Objective-C runtime.
746                    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    // SAFETY: `ns_view` is a valid NSView pointer obtained from Tauri's
930    // `with_webview` callback; null was checked above. `objc_msgSend` and
931    // `sel_registerName` are stable Objective-C runtime ABI.
932    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// ── Windows native (trusted) input helpers ─────────────────────────────────
949//
950// These deliver real OS input via SendInput, producing events with
951// `isTrusted: true` (unlike the JS bridge's synthetic events).
952
953#[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/// `SendInput` goes to whatever window has the foreground, not to a window we name: trusted
959/// input may only be sent once the target's top-level window IS the foreground window.
960#[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/// A trusted click lands on whatever window is topmost at the point: it must be the target.
976#[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/// A trusted click must land inside the target window's CLIENT area (physical pixels, client
991/// coordinates). Checking only that the point's top-level window is ours accepted the title
992/// bar, the caption buttons (close!) and the resize border, all of which belong to the same
993/// top-level window (R4-IN2). Non-finite coordinates are refused before any `as i32` cast,
994/// which would turn `NaN` into 0 and saturate huge values.
995#[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/// The top-level window `hwnd` belongs to (0 for none).
1016#[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    // SAFETY: GetAncestor accepts any HWND (including null) and returns null when there is none.
1021    unsafe { GetAncestor(hwnd, GA_ROOT) }.0 as isize
1022}
1023
1024/// Bring the target window to the foreground so input is routed to it, and verify it got
1025/// there. `SetForegroundWindow` is refused while the user is active in another app (Windows'
1026/// foreground lock); ignoring that sent the agent's keystrokes — Enter included — into the
1027/// developer's terminal.
1028#[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    // SAFETY: hwnd comes from Tauri's window handle; SetForegroundWindow is safe
1035    // to call with any HWND (returns false if it fails).
1036    unsafe {
1037        let _ = SetForegroundWindow(target);
1038    }
1039    // Give the OS a brief moment to apply focus, then poll briefly for it to take.
1040    std::thread::sleep(std::time::Duration::from_millis(40));
1041    let mut verdict = Ok(());
1042    for _ in 0..8 {
1043        // SAFETY: GetForegroundWindow takes no arguments and may return null.
1044        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/// Type Unicode text via `SendInput` (`KEYEVENTF_UNICODE` per UTF-16 code unit).
1086#[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    // SAFETY: `inputs` is a valid slice of properly-initialized INPUT structs.
1100    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/// Map a named key (Playwright-style) to a Win32 virtual-key code.
1112#[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/// Press and release a named key, or a single printable character, via `SendInput`.
1148#[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        // Single printable character → send as Unicode.
1159        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    // SAFETY: valid slice of initialized INPUT structs.
1175    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/// Click at logical (CSS-pixel) coordinates within the window's content area
1187/// via an absolute-positioned `SendInput` mouse sequence (move + down + up).
1188#[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    // SAFETY: GetDpiForWindow/GetClientRect/GetSystemMetrics/ClientToScreen are safe to call
1205    // with a valid HWND; GetClientRect and ClientToScreen write into our stack RECT/POINT.
1206    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        // Client coordinates: the client rect's origin is always (0, 0).
1211        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        // The click goes to the topmost window at the point, whichever app that is.
1221        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    // SAFETY: valid slice of initialized INPUT structs.
1254    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    /// A stand-in main thread that starts each posted job after `delay`.
1273    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    /// A stand-in main thread that is guaranteed to have STARTED the posted job before `post`
1284    /// returns (and so before the caller's deadline starts counting): it waits for the job to
1285    /// signal `started`. A plain spawned thread can be scheduled late on a loaded machine, and
1286    /// the caller then (correctly) abandons a job that never started — which turned tests
1287    /// about a job that outlives its caller into flakes.
1288    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        // It started before the caller gave up, so its effects happen: reporting "timed out"
1318        // would tell the caller a window change / eval did not happen when it did.
1319        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    /// R4-LOCK1: a round trip whose closure STARTED but outlived timeout + grace returned
1356    /// "outcome unknown" and released the dispatch lock while the closure was still running on
1357    /// the main thread — so the next caller put a second round trip in flight beside it, the
1358    /// exact condition `MAIN_DISPATCH_LOCK` exists to prevent (`WebKitGTK` heap corruption).
1359    #[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        // The first job is known to have started before its caller's deadline, and it runs
1367        // until released — no sleep decides either (both used to race a loaded scheduler).
1368        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        // Release the first job only once the second caller holds the dispatch lock — it is
1386        // then provably waiting on the still-running job, not merely about to start.
1387        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    /// The wait for an earlier caller's still-running job is bounded by the caller's OWN
1415    /// deadline (no N * timeout stacking), and a caller that gives up never runs its job.
1416    #[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    /// R4-IN2: a trusted click was only checked for landing on OUR top-level window — which
1455    /// includes its title bar and close button. It must land inside the client area.
1456    #[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),  // left of the client area (window border)
1463            (10.0, -30.0), // above it: the title bar / caption buttons
1464            (800.0, 10.0), // right edge is exclusive
1465            (10.0, 600.0),
1466            (f64::NAN, 10.0), // `NaN as i32` is 0: would silently click the corner
1467            (10.0, f64::INFINITY),
1468            (1e12, 10.0), // `as i32` saturates to i32::MAX
1469        ] {
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    /// A stand-in Tauri main thread: runs posted jobs in order, inline when posted from itself
1492    /// (exactly what tauri-runtime-wry's `send_user_message` does).
1493    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        /// Run `f` on the fake main thread and wait for it.
1526        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    /// R5B-MAIN1: Tauri lets an app register a plugin later from a background thread
1547    /// (`AppHandle::plugin`), and then the plugin's `setup` runs THERE. Recording that thread as
1548    /// "main" and installing the handle in its thread-local left every later `on_main` round
1549    /// trip failing with "the main-thread app handle is not installed". Adoption must happen on
1550    /// the real main thread.
1551    #[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        // The posted adoption runs before anything posted after it.
1559        main.run(|| ());
1560        let adopted = log.lock().unwrap().clone();
1561        assert_eq!(adopted, vec![(main.id, "app".to_string())]);
1562    }
1563
1564    /// The normal case — setup on the main thread — adopts in place, before returning.
1565    #[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            // Adopted before `adopt_on_main` returned, not merely queued.
1573            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        // Record from a dedicated thread (standing in for Tauri's setup thread): only that
1583        // thread is "main"; every other thread — including this test's — is not.
1584        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        // A second record from another thread must not move it (set-once).
1598        std::thread::spawn(record_main_thread).join().unwrap();
1599        assert!(!is_main_thread());
1600    }
1601}