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.
442///
443/// Within a [`CallBudget`] the wait for the closure to START is also capped by the time that
444/// agent call has left, so a busy UI thread cannot push a call far past its own deadline; a
445/// closure that already started is still waited for (its effects happen, so its real outcome is
446/// what the caller must report).
447fn on_main<R, T, F>(app: &tauri::AppHandle<R>, what: &str, f: F) -> Result<T, String>
448where
449    R: Runtime,
450    T: Send + 'static,
451    F: FnOnce(&tauri::AppHandle<R>) -> T + Send + 'static,
452{
453    let grace = MAIN_THREAD_TIMEOUT;
454    // Read on the CALLING thread (the budget is thread-local and `block_in_place` below keeps
455    // this call on it).
456    let timeout = start_timeout();
457
458    // Already ON the main thread (a sync Tauri command, a menu handler, or an `on_main` closure
459    // that calls back into the bridge): run inline, WITHOUT the dispatch lock. Taking the lock
460    // here could deadlock — a background holder waits for its closure, which is queued behind
461    // us on this very thread — freezing the UI for the full timeout. Skipping it is safe: no
462    // other closure can execute on this thread concurrently, and the heap corruption the lock
463    // prevents needs several cross-thread round trips in flight, which an inline call is not.
464    if is_main_thread() {
465        return std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(app)))
466            .map_err(|_| format!("{what} {PANICKED_ON_MAIN}"));
467    }
468    // The agent call this round trip serves is already out of time: dispatch nothing.
469    if timeout.is_zero() {
470        return Err(format!(
471            "{what} was not sent to the main thread: the call's deadline had passed (it will not \
472             run)"
473        ));
474    }
475
476    let round_trip = move || -> Result<T, String> {
477        // No handle is cloned here, on the calling thread (see above): the closure takes the
478        // main thread's own handle, and `app` is only borrowed to post it.
479        let owned_what = what.to_string();
480        serialized_round_trip(
481            &MAIN_DISPATCH_LOCK,
482            what,
483            timeout,
484            grace,
485            |job| {
486                app.run_on_main_thread(job)
487                    .map_err(|e| format!("failed to dispatch {what} to the main thread: {e}"))
488            },
489            move || {
490                main_app::<R>().map(|main| f(&main)).ok_or_else(|| {
491                    format!(
492                        "{owned_what}: the main-thread app handle is not installed (the plugin \
493                         setup has not run)"
494                    )
495                })
496            },
497        )?
498    };
499
500    // Blocking here must not park a tokio runtime worker — under a wedged UI that could
501    // otherwise starve the embedded axum/MCP server. On a multi-threaded runtime, `block_in_place`
502    // tells the scheduler to run other tasks elsewhere while this thread blocks. (It panics on a
503    // current-thread runtime, so guard on the flavor; with no runtime at all, just block.)
504    match tokio::runtime::Handle::try_current().map(|h| h.runtime_flavor()) {
505        Ok(tokio::runtime::RuntimeFlavor::MultiThread) => tokio::task::block_in_place(round_trip),
506        _ => round_trip(),
507    }
508}
509
510/// One serialized round trip: take `gate`'s lock (see [`MAIN_DISPATCH_LOCK`]), then hand `f` to `post`
511/// and wait for it.
512///
513/// `timeout` is one deadline shared by everything BEFORE the closure starts — the wait for the
514/// lock, for an earlier abandoned closure still running, and for this closure to be picked up —
515/// so serializing cannot stack N callers into N * timeout when the UI wedges: a closure that has
516/// not started by the deadline is abandoned (it never runs) and the call returns at `timeout`.
517/// A closure that DID start before the deadline is waited for up to a further `grace` (passed
518/// to [`dispatch_and_wait`]) so its real outcome is reported rather than "timed out" for work
519/// that happened. So a call returns within `timeout` if its closure never started, and within
520/// `timeout + grace` in the worst case (it started just before the deadline and then ran long).
521fn serialized_round_trip<T, F>(
522    gate: &'static DispatchGate,
523    what: &str,
524    timeout: std::time::Duration,
525    grace: std::time::Duration,
526    post: impl FnOnce(Box<dyn FnOnce() + Send>) -> Result<(), String>,
527    f: F,
528) -> Result<T, String>
529where
530    T: Send + 'static,
531    F: FnOnce() -> T + Send + 'static,
532{
533    let deadline = std::time::Instant::now() + timeout;
534    let lock_timeout = || {
535        format!(
536            "{what} did not complete on the main thread: ran out of time waiting for the \
537             main-thread dispatch lock (it will not run)"
538        )
539    };
540    let _serialize = loop {
541        match gate.lock.try_lock() {
542            Ok(guard) => break guard,
543            Err(std::sync::TryLockError::Poisoned(poisoned)) => break poisoned.into_inner(),
544            Err(std::sync::TryLockError::WouldBlock) => {
545                let remaining = deadline.saturating_duration_since(std::time::Instant::now());
546                if remaining.is_zero() {
547                    return Err(lock_timeout());
548                }
549                std::thread::sleep(remaining.min(std::time::Duration::from_millis(2)));
550            }
551        }
552    };
553    // An earlier caller may have given up on a closure that is STILL RUNNING on the main thread
554    // (it started, then outlived that caller's timeout + grace). Wait for it — bounded by this
555    // caller's own deadline, so callers never stack into N * timeout — before putting a second
556    // round trip in flight beside it.
557    // A plain sleep. (Round 4 briefly tried a yield spin here and removed it while chasing a
558    // Linux heap abort; the abort's real cause was the off-main-thread `AppHandle` clone
559    // documented on `on_main`, not the wait.)
560    while gate.in_flight.load(std::sync::atomic::Ordering::Acquire) != 0 {
561        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
562        if remaining.is_zero() {
563            return Err(format!(
564                "{what} did not complete on the main thread: timed out waiting for an earlier \
565                 main-thread call that is still running (it will not run)"
566            ));
567        }
568        std::thread::sleep(remaining.min(std::time::Duration::from_millis(2)));
569    }
570    let remaining = deadline.saturating_duration_since(std::time::Instant::now());
571    if remaining.is_zero() {
572        return Err(lock_timeout());
573    }
574    // Count the closure from BEFORE it can start (the count is raised ahead of the
575    // queued->running transition inside `dispatch_and_wait`'s job), so once a caller has seen
576    // its job start, the next lock holder is guaranteed to see it in flight.
577    let counted_post = move |job: Box<dyn FnOnce() + Send>| {
578        post(Box::new(move || {
579            gate.in_flight
580                .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
581            let _done = InFlightGuard(gate);
582            job();
583        }))
584    };
585    // Returns with the lock still held until the closure has either run or been abandoned,
586    // so no second round trip is ever in flight beside a closure that may still start.
587    dispatch_and_wait(what, remaining, grace, counted_post, f)
588}
589
590/// The longest a main-thread round trip waits for its closure to start, and the grace it then
591/// gives a closure that started (see [`serialized_round_trip`]).
592const MAIN_THREAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
593
594std::thread_local! {
595    /// The deadline of the agent call this thread is currently serving (see [`CallBudget`]).
596    static CALL_DEADLINE: std::cell::Cell<Option<std::time::Instant>> =
597        const { std::cell::Cell::new(None) };
598}
599
600/// While alive, caps how long a main-thread round trip made ON THIS THREAD may wait for its
601/// closure to start: by the time left until `deadline` (never more than
602/// [`MAIN_THREAD_TIMEOUT`]). An eval holds one around each synchronous bridge call so a busy UI
603/// thread cannot push the agent's call far past its own timeout — the closure that was not
604/// picked up in time is abandoned and never runs, so "not sent" stays true. Restores the
605/// previous budget on drop (nested budgets unwind in order). Hold it only around synchronous
606/// bridge calls: it is not `Send`, so a future holding one across an `.await` cannot be spawned
607/// onto a multi-threaded runtime, where it could resume on another thread.
608pub(crate) struct CallBudget {
609    previous: Option<std::time::Instant>,
610    _same_thread: std::marker::PhantomData<*const ()>,
611}
612
613impl CallBudget {
614    /// Budget the round trips made on this thread until the guard drops.
615    pub(crate) fn until(deadline: std::time::Instant) -> Self {
616        let previous = CALL_DEADLINE.with(|d| d.replace(Some(deadline)));
617        Self {
618            previous,
619            _same_thread: std::marker::PhantomData,
620        }
621    }
622}
623
624impl Drop for CallBudget {
625    fn drop(&mut self) {
626        CALL_DEADLINE.with(|d| d.set(self.previous));
627    }
628}
629
630/// How long a round trip started now on this thread may wait for its closure to start.
631pub(crate) fn start_timeout() -> std::time::Duration {
632    CALL_DEADLINE
633        .with(std::cell::Cell::get)
634        .map_or(MAIN_THREAD_TIMEOUT, |deadline| {
635            deadline
636                .saturating_duration_since(std::time::Instant::now())
637                .min(MAIN_THREAD_TIMEOUT)
638        })
639}
640
641/// A dispatched closure that has not started yet.
642const JOB_QUEUED: u8 = 0;
643/// A dispatched closure that has started (its effects will happen).
644const JOB_RUNNING: u8 = 1;
645/// A dispatched closure its caller gave up on before it started: it must never run.
646const JOB_ABANDONED: u8 = 2;
647
648/// Hand `f` to `post` (which queues it on the main thread) and wait up to `remaining` for it.
649///
650/// The closure and a caller that times out race through ONE compare-and-swap: the closure only
651/// runs if it moves Queued -> Running first, and the caller only reports a timeout if it moves
652/// Queued -> Abandoned first. So a timeout error always means the work never happened and never
653/// will — a state-mutating op (a resize, move, close, title change or eval) cannot apply after
654/// the caller already saw the error and moved on. A closure that started just before the
655/// deadline is waited for (up to `grace`) and its real outcome returned, instead of "timed out"
656/// for work that ran.
657fn dispatch_and_wait<T, F>(
658    what: &str,
659    remaining: std::time::Duration,
660    grace: std::time::Duration,
661    post: impl FnOnce(Box<dyn FnOnce() + Send>) -> Result<(), String>,
662    f: F,
663) -> Result<T, String>
664where
665    T: Send + 'static,
666    F: FnOnce() -> T + Send + 'static,
667{
668    use std::sync::atomic::{AtomicU8, Ordering};
669    use std::sync::mpsc::RecvTimeoutError;
670
671    let (tx, rx) = std::sync::mpsc::channel();
672    let state = std::sync::Arc::new(AtomicU8::new(JOB_QUEUED));
673    let job_state = std::sync::Arc::clone(&state);
674    post(Box::new(move || {
675        if job_state
676            .compare_exchange(JOB_QUEUED, JOB_RUNNING, Ordering::AcqRel, Ordering::Acquire)
677            .is_err()
678        {
679            return;
680        }
681        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
682        // Send only fails if the caller already gave up and dropped the receiver — ignore.
683        let _ = tx.send(result);
684    }))?;
685
686    let outcome = match rx.recv_timeout(remaining) {
687        Ok(outcome) => outcome,
688        Err(RecvTimeoutError::Disconnected) => {
689            return Err(format!(
690                "{what} did not complete on the main thread: the event loop dropped it"
691            ));
692        }
693        Err(RecvTimeoutError::Timeout) => {
694            if state
695                .compare_exchange(
696                    JOB_QUEUED,
697                    JOB_ABANDONED,
698                    Ordering::AcqRel,
699                    Ordering::Acquire,
700                )
701                .is_ok()
702            {
703                return Err(format!(
704                    "{what} did not complete on the main thread: timed out before it started \
705                     (it will not run)"
706                ));
707            }
708            // It started before we gave up: its effects will happen, so report its outcome.
709            rx.recv_timeout(grace)
710                .map_err(|_| format!("{what} {STARTED_OUTCOME_UNKNOWN}"))?
711        }
712    };
713    outcome.map_err(|_panic| format!("{what} {PANICKED_ON_MAIN}"))
714}
715
716/// Error text of a main-thread closure that started but outlived its grace.
717const STARTED_OUTCOME_UNKNOWN: &str =
718    "started on the main thread but did not finish in time; its outcome is unknown";
719/// Error text of a main-thread closure that panicked.
720const PANICKED_ON_MAIN: &str = "panicked on the main thread";
721
722/// Whether a bridge error means the closure STARTED on the main thread, so its effects may
723/// have happened (it outlived its grace, or panicked part-way) — as opposed to a dispatch that
724/// never ran. An eval must not report "the code did NOT run" for these.
725pub(crate) fn closure_may_have_run(error: &str) -> bool {
726    error.contains(STARTED_OUTCOME_UNKNOWN) || error.contains(PANICKED_ON_MAIN)
727}
728
729impl<R: Runtime> WebviewBridge for tauri::AppHandle<R> {
730    fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
731        let label = label.map(str::to_string);
732        let script = script.to_string();
733        on_main(self, "eval_webview", move |app| {
734            let windows = app.webview_windows();
735            let webview = find_window(&windows, label.as_deref())?;
736            webview.eval(&script).map_err(|e| e.to_string())
737        })?
738    }
739
740    fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
741        // An empty Vec here means the main-thread dispatch failed/timed out (a wedged UI), not
742        // "no windows" — log it so that case is diagnosable rather than silently indistinguishable.
743        self.try_get_window_states(label).unwrap_or_else(|e| {
744            tracing::warn!("get_window_states: {e}");
745            Vec::new()
746        })
747    }
748
749    fn try_get_window_states(&self, label: Option<&str>) -> Result<Vec<WindowState>, String> {
750        let label = label.map(str::to_string);
751        on_main(self, "get_window_states", move |app| {
752            let windows = app.webview_windows();
753            let mut states = Vec::new();
754
755            for (win_label, window) in &windows {
756                if let Some(filter) = label.as_deref()
757                    && win_label != filter
758                {
759                    continue;
760                }
761
762                let pos = window.outer_position().unwrap_or_default();
763                let size = window.inner_size().unwrap_or_default();
764
765                states.push(
766                    WindowState::new(win_label.clone())
767                        .with_title(window.title().unwrap_or_default())
768                        .with_url(window.url().map(|u| u.to_string()).unwrap_or_default())
769                        .with_visible(window.is_visible().unwrap_or(false))
770                        .with_focused(window.is_focused().unwrap_or(false))
771                        .with_maximized(window.is_maximized().unwrap_or(false))
772                        .with_minimized(window.is_minimized().unwrap_or(false))
773                        .with_fullscreen(window.is_fullscreen().unwrap_or(false))
774                        .with_position(pos.x, pos.y)
775                        .with_size(size.width, size.height),
776                );
777            }
778
779            states
780        })
781    }
782
783    fn list_window_labels(&self) -> Vec<String> {
784        self.try_list_window_labels().unwrap_or_else(|e| {
785            tracing::warn!("list_window_labels: {e}");
786            Vec::new()
787        })
788    }
789
790    fn try_list_window_labels(&self) -> Result<Vec<String>, String> {
791        on_main(self, "list_window_labels", |app| {
792            app.webview_windows().keys().cloned().collect()
793        })
794    }
795
796    fn eval_webview_resolved(&self, label: Option<&str>, script: &str) -> Result<String, String> {
797        let label = label.map(str::to_string);
798        let script = script.to_string();
799        on_main(self, "eval_webview", move |app| {
800            let windows = app.webview_windows();
801            let webview = find_window(&windows, label.as_deref())?;
802            webview.eval(&script).map_err(|e| e.to_string())?;
803            Ok(webview.label().to_string())
804        })?
805    }
806
807    fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
808        let label = label.map(str::to_string);
809        on_main(self, "get_native_handle", move |app| {
810            use raw_window_handle::{HasWindowHandle, RawWindowHandle};
811
812            let windows = app.webview_windows();
813            let _webview = find_window(&windows, label.as_deref())?;
814            let handle = _webview.window_handle().map_err(|e| e.to_string())?;
815            match handle.as_raw() {
816                #[cfg(windows)]
817                RawWindowHandle::Win32(h) => Ok(h.hwnd.get()),
818                #[cfg(target_os = "macos")]
819                RawWindowHandle::AppKit(h) => {
820                    // CGWindowListCreateImage needs CGWindowID (the window number),
821                    // not the NSView pointer. Extract via Objective-C runtime.
822                    macos_window_number(h.ns_view.as_ptr())
823                }
824                #[cfg(target_os = "linux")]
825                RawWindowHandle::Xlib(h) => Ok(h.window as isize),
826                #[cfg(target_os = "linux")]
827                RawWindowHandle::Xcb(h) => Ok(h.window.get() as isize),
828                _ => Err("unsupported window handle type on this platform".to_string()),
829            }
830        })?
831    }
832
833    #[cfg(windows)]
834    fn native_type_text(&self, label: Option<&str>, text: &str) -> Result<(), String> {
835        let hwnd = self.get_native_handle(label)?;
836        win_focus(hwnd)?;
837        win_send_text(text)
838    }
839
840    #[cfg(windows)]
841    fn native_key(&self, label: Option<&str>, key: &str) -> Result<(), String> {
842        let hwnd = self.get_native_handle(label)?;
843        win_focus(hwnd)?;
844        win_send_key(key)
845    }
846
847    #[cfg(windows)]
848    fn native_click(&self, label: Option<&str>, x: f64, y: f64) -> Result<(), String> {
849        let hwnd = self.get_native_handle(label)?;
850        win_focus(hwnd)?;
851        win_click(hwnd, x, y)
852    }
853
854    fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
855        let label = label.map(str::to_string);
856        let action = action.to_string();
857        on_main(self, "manage_window", move |app| {
858            let windows = app.webview_windows();
859            let window = find_window(&windows, label.as_deref())?;
860
861            match action.as_str() {
862                "minimize" => window.minimize().map_err(|e| e.to_string())?,
863                "unminimize" => window.unminimize().map_err(|e| e.to_string())?,
864                "maximize" => window.maximize().map_err(|e| e.to_string())?,
865                "unmaximize" => window.unmaximize().map_err(|e| e.to_string())?,
866                "close" => window.close().map_err(|e| e.to_string())?,
867                "focus" => window.set_focus().map_err(|e| e.to_string())?,
868                "show" => window.show().map_err(|e| e.to_string())?,
869                "hide" => window.hide().map_err(|e| e.to_string())?,
870                "fullscreen" => window.set_fullscreen(true).map_err(|e| e.to_string())?,
871                "unfullscreen" => window.set_fullscreen(false).map_err(|e| e.to_string())?,
872                "always_on_top" => window.set_always_on_top(true).map_err(|e| e.to_string())?,
873                "not_always_on_top" => {
874                    window.set_always_on_top(false).map_err(|e| e.to_string())?;
875                }
876                _ => return Err(format!("unknown action: {action}")),
877            }
878
879            Ok(format!("{action} executed"))
880        })?
881    }
882
883    fn resize_window(&self, label: Option<&str>, width: u32, height: u32) -> Result<(), String> {
884        let label = label.map(str::to_string);
885        on_main(self, "resize_window", move |app| {
886            let windows = app.webview_windows();
887            let window = find_window(&windows, label.as_deref())?;
888
889            window
890                .set_size(tauri::LogicalSize::new(width, height))
891                .map_err(|e| e.to_string())
892        })?
893    }
894
895    fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
896        let label = label.map(str::to_string);
897        on_main(self, "move_window", move |app| {
898            let windows = app.webview_windows();
899            let window = find_window(&windows, label.as_deref())?;
900
901            window
902                .set_position(tauri::LogicalPosition::new(x, y))
903                .map_err(|e| e.to_string())
904        })?
905    }
906
907    fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
908        let label = label.map(str::to_string);
909        let title = title.to_string();
910        on_main(self, "set_window_title", move |app| {
911            let windows = app.webview_windows();
912            let window = find_window(&windows, label.as_deref())?;
913
914            window.set_title(&title).map_err(|e| e.to_string())
915        })?
916    }
917
918    fn app_data_dir(&self) -> Result<std::path::PathBuf, String> {
919        self.path().app_data_dir().map_err(|e| e.to_string())
920    }
921
922    fn app_config_dir(&self) -> Result<std::path::PathBuf, String> {
923        self.path().app_config_dir().map_err(|e| e.to_string())
924    }
925
926    fn app_log_dir(&self) -> Result<std::path::PathBuf, String> {
927        self.path().app_log_dir().map_err(|e| e.to_string())
928    }
929
930    fn app_local_data_dir(&self) -> Result<std::path::PathBuf, String> {
931        self.path().app_local_data_dir().map_err(|e| e.to_string())
932    }
933
934    fn tauri_config(&self) -> serde_json::Value {
935        let config = self.config();
936
937        let windows: Vec<serde_json::Value> = config
938            .app
939            .windows
940            .iter()
941            .map(|w| {
942                serde_json::json!({
943                    "label": w.label,
944                    "title": w.title,
945                    "url": format!("{}", w.url),
946                    "width": w.width,
947                    "height": w.height,
948                    "visible": w.visible,
949                    "resizable": w.resizable,
950                    "fullscreen": w.fullscreen,
951                    "decorations": w.decorations,
952                    "transparent": w.transparent,
953                    "always_on_top": w.always_on_top,
954                })
955            })
956            .collect();
957
958        let plugins: Vec<String> = config.plugins.0.keys().cloned().collect();
959
960        let security = serde_json::json!({
961            "csp": config.app.security.csp.as_ref().map(|c| format!("{c}")),
962            "freeze_prototype": config.app.security.freeze_prototype,
963            "capabilities": config.app.security.capabilities.iter().map(|c| {
964                match c {
965                    tauri::utils::config::CapabilityEntry::Inlined(cap) => {
966                        serde_json::json!({
967                            "identifier": cap.identifier,
968                            "description": cap.description,
969                            "windows": cap.windows,
970                            "webviews": cap.webviews,
971                            "permissions": cap.permissions.iter().map(|p| format!("{p:?}")).collect::<Vec<_>>(),
972                            "platforms": cap.platforms,
973                        })
974                    }
975                    tauri::utils::config::CapabilityEntry::Reference(path) => {
976                        serde_json::json!({ "reference": path })
977                    }
978                }
979            }).collect::<Vec<_>>(),
980        });
981
982        serde_json::json!({
983            "identifier": config.identifier,
984            "product_name": config.product_name,
985            "version": config.version,
986            "windows": windows,
987            "plugins": plugins,
988            "security": security,
989        })
990    }
991}
992
993#[cfg(target_os = "macos")]
994#[allow(unsafe_code)]
995fn macos_window_number(ns_view: *mut std::ffi::c_void) -> Result<isize, String> {
996    unsafe extern "C" {
997        fn objc_msgSend(obj: *mut std::ffi::c_void, sel: *mut std::ffi::c_void) -> isize;
998        fn sel_registerName(name: *const std::ffi::c_char) -> *mut std::ffi::c_void;
999    }
1000
1001    if ns_view.is_null() {
1002        return Err("null NSView handle".to_string());
1003    }
1004
1005    // SAFETY: `ns_view` is a valid NSView pointer obtained from Tauri's
1006    // `with_webview` callback; null was checked above. `objc_msgSend` and
1007    // `sel_registerName` are stable Objective-C runtime ABI.
1008    unsafe {
1009        let sel_window = sel_registerName(c"window".as_ptr());
1010        let ns_window = objc_msgSend(ns_view, sel_window);
1011        if ns_window == 0 {
1012            return Err("NSView has no parent NSWindow".to_string());
1013        }
1014        let sel_window_number = sel_registerName(c"windowNumber".as_ptr());
1015        let ns_window_ptr = ns_window as *mut std::ffi::c_void;
1016        let window_number = objc_msgSend(ns_window_ptr, sel_window_number);
1017        if window_number <= 0 {
1018            return Err(format!("invalid CGWindowID: {window_number}"));
1019        }
1020        Ok(window_number)
1021    }
1022}
1023
1024// ── Windows native (trusted) input helpers ─────────────────────────────────
1025//
1026// These deliver real OS input via SendInput, producing events with
1027// `isTrusted: true` (unlike the JS bridge's synthetic events).
1028
1029#[cfg(windows)]
1030fn win_hwnd(hwnd: isize) -> windows::Win32::Foundation::HWND {
1031    windows::Win32::Foundation::HWND(hwnd as *mut core::ffi::c_void)
1032}
1033
1034/// `SendInput` goes to whatever window has the foreground, not to a window we name: trusted
1035/// input may only be sent once the target's top-level window IS the foreground window.
1036#[cfg(any(windows, test))]
1037fn foreground_verdict(target_root: isize, foreground_root: isize) -> Result<(), String> {
1038    if target_root != 0 && target_root == foreground_root {
1039        Ok(())
1040    } else {
1041        Err(
1042            "refusing trusted input: the app window could not be brought to the foreground \
1043             (Windows' foreground lock keeps focus with the app the user is working in, e.g. a \
1044             terminal), so the keystrokes or click would go to that app instead. Bring the app \
1045             window to the front and retry, or omit `trusted` to use synthetic input."
1046                .to_string(),
1047        )
1048    }
1049}
1050
1051/// A trusted click lands on whatever window is topmost at the point: it must be the target.
1052#[cfg(any(windows, test))]
1053fn click_target_verdict(target_root: isize, point_root: isize) -> Result<(), String> {
1054    if target_root != 0 && target_root == point_root {
1055        Ok(())
1056    } else {
1057        Err(
1058            "refusing trusted click: the point is covered by another window (or lies outside \
1059             this one), so the click would land there. Make the element visible on screen and \
1060             retry, or omit `trusted` to use a synthetic click."
1061                .to_string(),
1062        )
1063    }
1064}
1065
1066/// A trusted click must land inside the target window's CLIENT area (physical pixels, client
1067/// coordinates). Checking only that the point's top-level window is ours accepted the title
1068/// bar, the caption buttons (close!) and the resize border, all of which belong to the same
1069/// top-level window (R4-IN2). Non-finite coordinates are refused before any `as i32` cast,
1070/// which would turn `NaN` into 0 and saturate huge values.
1071#[cfg(any(windows, test))]
1072fn client_point_verdict(x: f64, y: f64, client_w: i32, client_h: i32) -> Result<(), String> {
1073    let inside = x.is_finite()
1074        && y.is_finite()
1075        && x >= 0.0
1076        && y >= 0.0
1077        && x < f64::from(client_w)
1078        && y < f64::from(client_h);
1079    if inside {
1080        Ok(())
1081    } else {
1082        Err(format!(
1083            "refusing trusted click: ({x}, {y}) lies outside the window's content area \
1084             ({client_w}x{client_h} physical px), so it would land on the title bar, a caption \
1085             button or the border. Scroll the element into view and retry, or omit `trusted` to \
1086             use a synthetic click."
1087        ))
1088    }
1089}
1090
1091/// The top-level window `hwnd` belongs to (0 for none).
1092#[allow(unsafe_code)]
1093#[cfg(windows)]
1094fn win_root(hwnd: windows::Win32::Foundation::HWND) -> isize {
1095    use windows::Win32::UI::WindowsAndMessaging::{GA_ROOT, GetAncestor};
1096    // SAFETY: GetAncestor accepts any HWND (including null) and returns null when there is none.
1097    unsafe { GetAncestor(hwnd, GA_ROOT) }.0 as isize
1098}
1099
1100/// Bring the target window to the foreground so input is routed to it, and verify it got
1101/// there. `SetForegroundWindow` is refused while the user is active in another app (Windows'
1102/// foreground lock); ignoring that sent the agent's keystrokes — Enter included — into the
1103/// developer's terminal.
1104#[allow(unsafe_code)]
1105#[cfg(windows)]
1106fn win_focus(hwnd: isize) -> Result<(), String> {
1107    use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, SetForegroundWindow};
1108    let target = win_hwnd(hwnd);
1109    let target_root = win_root(target);
1110    // SAFETY: hwnd comes from Tauri's window handle; SetForegroundWindow is safe
1111    // to call with any HWND (returns false if it fails).
1112    unsafe {
1113        let _ = SetForegroundWindow(target);
1114    }
1115    // Give the OS a brief moment to apply focus, then poll briefly for it to take.
1116    std::thread::sleep(std::time::Duration::from_millis(40));
1117    let mut verdict = Ok(());
1118    for _ in 0..8 {
1119        // SAFETY: GetForegroundWindow takes no arguments and may return null.
1120        verdict = foreground_verdict(target_root, win_root(unsafe { GetForegroundWindow() }));
1121        if verdict.is_ok() {
1122            break;
1123        }
1124        std::thread::sleep(std::time::Duration::from_millis(20));
1125    }
1126    verdict
1127}
1128
1129#[cfg(windows)]
1130fn win_keyboard_input(
1131    vk: u16,
1132    scan: u16,
1133    key_up: bool,
1134    unicode: bool,
1135) -> windows::Win32::UI::Input::KeyboardAndMouse::INPUT {
1136    use windows::Win32::UI::Input::KeyboardAndMouse::{
1137        INPUT, INPUT_0, INPUT_KEYBOARD, KEYBD_EVENT_FLAGS, KEYBDINPUT, KEYEVENTF_KEYUP,
1138        KEYEVENTF_UNICODE, VIRTUAL_KEY,
1139    };
1140    let mut flags = KEYBD_EVENT_FLAGS(0);
1141    if unicode {
1142        flags |= KEYEVENTF_UNICODE;
1143    }
1144    if key_up {
1145        flags |= KEYEVENTF_KEYUP;
1146    }
1147    INPUT {
1148        r#type: INPUT_KEYBOARD,
1149        Anonymous: INPUT_0 {
1150            ki: KEYBDINPUT {
1151                wVk: VIRTUAL_KEY(vk),
1152                wScan: scan,
1153                dwFlags: flags,
1154                time: 0,
1155                dwExtraInfo: 0,
1156            },
1157        },
1158    }
1159}
1160
1161/// Type Unicode text via `SendInput` (`KEYEVENTF_UNICODE` per UTF-16 code unit).
1162#[allow(unsafe_code)]
1163#[cfg(windows)]
1164fn win_send_text(text: &str) -> Result<(), String> {
1165    use windows::Win32::UI::Input::KeyboardAndMouse::{INPUT, SendInput};
1166    let mut inputs: Vec<INPUT> = Vec::new();
1167    for unit in text.encode_utf16() {
1168        inputs.push(win_keyboard_input(0, unit, false, true));
1169        inputs.push(win_keyboard_input(0, unit, true, true));
1170    }
1171    if inputs.is_empty() {
1172        return Ok(());
1173    }
1174    let cb = i32::try_from(std::mem::size_of::<INPUT>()).unwrap_or(0);
1175    // SAFETY: `inputs` is a valid slice of properly-initialized INPUT structs.
1176    let sent = unsafe { SendInput(&inputs, cb) } as usize;
1177    if sent == inputs.len() {
1178        Ok(())
1179    } else {
1180        Err(format!(
1181            "SendInput delivered {sent}/{} key events",
1182            inputs.len()
1183        ))
1184    }
1185}
1186
1187/// Map a named key (Playwright-style) to a Win32 virtual-key code.
1188#[cfg(windows)]
1189fn win_vk_for_key(key: &str) -> Option<u16> {
1190    use windows::Win32::UI::Input::KeyboardAndMouse as k;
1191    let vk = match key {
1192        "Enter" | "Return" => k::VK_RETURN,
1193        "Tab" => k::VK_TAB,
1194        "Escape" | "Esc" => k::VK_ESCAPE,
1195        "Backspace" => k::VK_BACK,
1196        "Delete" | "Del" => k::VK_DELETE,
1197        "ArrowUp" | "Up" => k::VK_UP,
1198        "ArrowDown" | "Down" => k::VK_DOWN,
1199        "ArrowLeft" | "Left" => k::VK_LEFT,
1200        "ArrowRight" | "Right" => k::VK_RIGHT,
1201        "Home" => k::VK_HOME,
1202        "End" => k::VK_END,
1203        "PageUp" => k::VK_PRIOR,
1204        "PageDown" => k::VK_NEXT,
1205        "Space" | " " => k::VK_SPACE,
1206        "F1" => k::VK_F1,
1207        "F2" => k::VK_F2,
1208        "F3" => k::VK_F3,
1209        "F4" => k::VK_F4,
1210        "F5" => k::VK_F5,
1211        "F6" => k::VK_F6,
1212        "F7" => k::VK_F7,
1213        "F8" => k::VK_F8,
1214        "F9" => k::VK_F9,
1215        "F10" => k::VK_F10,
1216        "F11" => k::VK_F11,
1217        "F12" => k::VK_F12,
1218        _ => return None,
1219    };
1220    Some(vk.0)
1221}
1222
1223/// Press and release a named key, or a single printable character, via `SendInput`.
1224#[allow(unsafe_code)]
1225#[cfg(windows)]
1226fn win_send_key(key: &str) -> Result<(), String> {
1227    use windows::Win32::UI::Input::KeyboardAndMouse::{INPUT, SendInput};
1228    let inputs: Vec<INPUT> = if let Some(vk) = win_vk_for_key(key) {
1229        vec![
1230            win_keyboard_input(vk, 0, false, false),
1231            win_keyboard_input(vk, 0, true, false),
1232        ]
1233    } else {
1234        // Single printable character → send as Unicode.
1235        let mut chars = key.chars();
1236        let (Some(c), None) = (chars.next(), chars.next()) else {
1237            return Err(format!(
1238                "unknown key '{key}' (use a named key or a single character)"
1239            ));
1240        };
1241        let mut buf = [0u16; 2];
1242        let mut v = Vec::new();
1243        for unit in c.encode_utf16(&mut buf) {
1244            v.push(win_keyboard_input(0, *unit, false, true));
1245            v.push(win_keyboard_input(0, *unit, true, true));
1246        }
1247        v
1248    };
1249    let cb = i32::try_from(std::mem::size_of::<INPUT>()).unwrap_or(0);
1250    // SAFETY: valid slice of initialized INPUT structs.
1251    let sent = unsafe { SendInput(&inputs, cb) } as usize;
1252    if sent == inputs.len() {
1253        Ok(())
1254    } else {
1255        Err(format!(
1256            "SendInput delivered {sent}/{} key events",
1257            inputs.len()
1258        ))
1259    }
1260}
1261
1262/// Click at logical (CSS-pixel) coordinates within the window's content area
1263/// via an absolute-positioned `SendInput` mouse sequence (move + down + up).
1264#[allow(unsafe_code)]
1265#[cfg(windows)]
1266fn win_click(hwnd: isize, x: f64, y: f64) -> Result<(), String> {
1267    use windows::Win32::Foundation::POINT;
1268    use windows::Win32::Foundation::RECT;
1269    use windows::Win32::Graphics::Gdi::ClientToScreen;
1270    use windows::Win32::UI::HiDpi::GetDpiForWindow;
1271    use windows::Win32::UI::Input::KeyboardAndMouse::{
1272        INPUT, INPUT_0, INPUT_MOUSE, MOUSE_EVENT_FLAGS, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_LEFTDOWN,
1273        MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_VIRTUALDESK, MOUSEINPUT, SendInput,
1274    };
1275    use windows::Win32::UI::WindowsAndMessaging::{
1276        GetClientRect, GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
1277        SM_YVIRTUALSCREEN, WindowFromPoint,
1278    };
1279    let h = win_hwnd(hwnd);
1280    // SAFETY: GetDpiForWindow/GetClientRect/GetSystemMetrics/ClientToScreen are safe to call
1281    // with a valid HWND; GetClientRect and ClientToScreen write into our stack RECT/POINT.
1282    let (nx, ny) = unsafe {
1283        let dpi = GetDpiForWindow(h);
1284        let scale = if dpi == 0 { 1.0 } else { f64::from(dpi) / 96.0 };
1285        let (px, py) = (x * scale, y * scale);
1286        // Client coordinates: the client rect's origin is always (0, 0).
1287        let mut client = RECT::default();
1288        GetClientRect(h, &mut client)
1289            .map_err(|e| format!("refusing trusted click: cannot read the client area: {e}"))?;
1290        client_point_verdict(px, py, client.right, client.bottom)?;
1291        let mut pt = POINT {
1292            x: px as i32,
1293            y: py as i32,
1294        };
1295        let _ = ClientToScreen(h, &mut pt);
1296        // The click goes to the topmost window at the point, whichever app that is.
1297        click_target_verdict(win_root(h), win_root(WindowFromPoint(pt)))?;
1298        let vx = GetSystemMetrics(SM_XVIRTUALSCREEN);
1299        let vy = GetSystemMetrics(SM_YVIRTUALSCREEN);
1300        let vw = GetSystemMetrics(SM_CXVIRTUALSCREEN);
1301        let vh = GetSystemMetrics(SM_CYVIRTUALSCREEN);
1302        if vw <= 1 || vh <= 1 {
1303            return Err("virtual screen metrics unavailable".to_string());
1304        }
1305        let nx = ((f64::from(pt.x - vx)) * 65535.0 / f64::from(vw - 1)) as i32;
1306        let ny = ((f64::from(pt.y - vy)) * 65535.0 / f64::from(vh - 1)) as i32;
1307        (nx, ny)
1308    };
1309    let make = |flags: MOUSE_EVENT_FLAGS| INPUT {
1310        r#type: INPUT_MOUSE,
1311        Anonymous: INPUT_0 {
1312            mi: MOUSEINPUT {
1313                dx: nx,
1314                dy: ny,
1315                mouseData: 0,
1316                dwFlags: flags,
1317                time: 0,
1318                dwExtraInfo: 0,
1319            },
1320        },
1321    };
1322    let base = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK;
1323    let inputs = [
1324        make(base | MOUSEEVENTF_MOVE),
1325        make(base | MOUSEEVENTF_LEFTDOWN),
1326        make(base | MOUSEEVENTF_LEFTUP),
1327    ];
1328    let cb = i32::try_from(std::mem::size_of::<INPUT>()).unwrap_or(0);
1329    // SAFETY: valid slice of initialized INPUT structs.
1330    let sent = unsafe { SendInput(&inputs, cb) } as usize;
1331    if sent == inputs.len() {
1332        Ok(())
1333    } else {
1334        Err(format!(
1335            "SendInput delivered {sent}/{} mouse events",
1336            inputs.len()
1337        ))
1338    }
1339}
1340
1341#[cfg(test)]
1342mod tests {
1343    use super::{dispatch_and_wait, is_main_thread, record_main_thread};
1344    use std::sync::Arc;
1345    use std::sync::atomic::{AtomicUsize, Ordering};
1346    use std::time::Duration;
1347
1348    /// A stand-in main thread that starts each posted job after `delay`.
1349    fn post_after(delay: Duration) -> impl FnOnce(Box<dyn FnOnce() + Send>) -> Result<(), String> {
1350        move |job| {
1351            std::thread::spawn(move || {
1352                std::thread::sleep(delay);
1353                job();
1354            });
1355            Ok(())
1356        }
1357    }
1358
1359    /// A stand-in main thread that is guaranteed to have STARTED the posted job before `post`
1360    /// returns (and so before the caller's deadline starts counting): it waits for the job to
1361    /// signal `started`. A plain spawned thread can be scheduled late on a loaded machine, and
1362    /// the caller then (correctly) abandons a job that never started — which turned tests
1363    /// about a job that outlives its caller into flakes.
1364    fn post_started(
1365        started: std::sync::mpsc::Receiver<()>,
1366    ) -> impl FnOnce(Box<dyn FnOnce() + Send>) -> Result<(), String> {
1367        move |job| {
1368            std::thread::spawn(job);
1369            started
1370                .recv_timeout(Duration::from_secs(60))
1371                .map_err(|_| "the stand-in main thread never started the job".to_string())
1372        }
1373    }
1374
1375    #[test]
1376    fn a_job_still_queued_at_the_deadline_never_runs() {
1377        let ran = Arc::new(AtomicUsize::new(0));
1378        let r = Arc::clone(&ran);
1379        let out = dispatch_and_wait(
1380            "resize_window",
1381            Duration::from_millis(50),
1382            Duration::from_secs(5),
1383            post_after(Duration::from_millis(300)),
1384            move || r.fetch_add(1, Ordering::SeqCst),
1385        );
1386        assert!(out.unwrap_err().contains("will not run"));
1387        std::thread::sleep(Duration::from_millis(500));
1388        assert_eq!(ran.load(Ordering::SeqCst), 0, "an abandoned job ran anyway");
1389    }
1390
1391    #[test]
1392    fn a_job_running_at_the_deadline_reports_its_real_outcome() {
1393        // It started before the caller gave up, so its effects happen: reporting "timed out"
1394        // would tell the caller a window change / eval did not happen when it did.
1395        let (started_tx, started_rx) = std::sync::mpsc::channel();
1396        let out = dispatch_and_wait(
1397            "eval_webview",
1398            Duration::from_millis(50),
1399            Duration::from_secs(5),
1400            post_started(started_rx),
1401            move || {
1402                let _ = started_tx.send(());
1403                std::thread::sleep(Duration::from_millis(300));
1404                7
1405            },
1406        );
1407        assert_eq!(out, Ok(7));
1408    }
1409
1410    #[test]
1411    fn trusted_input_is_refused_unless_the_target_is_in_front() {
1412        use super::{click_target_verdict, foreground_verdict};
1413        assert!(foreground_verdict(0x42, 0x42).is_ok());
1414        let other = foreground_verdict(0x42, 0x99).unwrap_err();
1415        assert!(other.contains("foreground"), "{other}");
1416        assert!(foreground_verdict(0x42, 0).is_err(), "no foreground window");
1417        assert!(foreground_verdict(0, 0).is_err(), "unknown target window");
1418        assert!(click_target_verdict(0x42, 0x42).is_ok());
1419        assert!(
1420            click_target_verdict(0x42, 0x99)
1421                .unwrap_err()
1422                .contains("covered")
1423        );
1424        assert!(click_target_verdict(0, 0).is_err());
1425    }
1426
1427    fn leaked_gate() -> &'static super::DispatchGate {
1428        Box::leak(Box::new(super::DispatchGate::new()))
1429    }
1430
1431    /// A call budget caps how long a round trip on this thread waits for its closure to START:
1432    /// never past the agent call's deadline, never above the 10 s default, zero once it has
1433    /// passed; nested budgets restore the outer one, and no budget means the default.
1434    #[test]
1435    fn a_call_budget_caps_the_start_wait_and_restores_on_drop() {
1436        use super::{CallBudget, MAIN_THREAD_TIMEOUT, start_timeout};
1437        use std::time::Instant;
1438        assert_eq!(start_timeout(), MAIN_THREAD_TIMEOUT);
1439        {
1440            let _outer = CallBudget::until(Instant::now() + Duration::from_secs(5));
1441            assert!(start_timeout() <= Duration::from_secs(5));
1442            {
1443                let _inner = CallBudget::until(Instant::now() - Duration::from_millis(1));
1444                assert_eq!(
1445                    start_timeout(),
1446                    Duration::ZERO,
1447                    "an expired budget waits for nothing"
1448                );
1449            }
1450            let back = start_timeout();
1451            assert!(
1452                back > Duration::ZERO && back <= Duration::from_secs(5),
1453                "the inner budget restored the outer one: {back:?}"
1454            );
1455            let _long = CallBudget::until(Instant::now() + Duration::from_secs(3600));
1456            assert_eq!(
1457                start_timeout(),
1458                MAIN_THREAD_TIMEOUT,
1459                "never above the default"
1460            );
1461        }
1462        assert_eq!(
1463            start_timeout(),
1464            MAIN_THREAD_TIMEOUT,
1465            "dropped: back to no budget"
1466        );
1467        // Thread-local: another thread never sees this thread's budget.
1468        let _here = CallBudget::until(Instant::now());
1469        let elsewhere = std::thread::spawn(start_timeout).join().unwrap();
1470        assert_eq!(elsewhere, MAIN_THREAD_TIMEOUT);
1471    }
1472
1473    /// The budget reaches a round trip through `start_timeout` — what `on_main` passes — so a
1474    /// closure the main thread never picks up is abandoned at the call's deadline, not at 10 s.
1475    #[test]
1476    fn a_round_trip_under_a_call_budget_gives_up_at_the_calls_deadline() {
1477        use super::{CallBudget, serialized_round_trip, start_timeout};
1478        use std::time::Instant;
1479        let gate = leaked_gate();
1480        let _budget = CallBudget::until(Instant::now() + Duration::from_millis(80));
1481        let begun = Instant::now();
1482        let parked: Arc<std::sync::Mutex<Vec<Box<dyn FnOnce() + Send>>>> = Arc::default();
1483        let park = Arc::clone(&parked);
1484        let refused = serialized_round_trip(
1485            gate,
1486            "budgeted",
1487            start_timeout(),
1488            Duration::from_secs(5),
1489            move |job| {
1490                park.lock().unwrap().push(job);
1491                Ok(())
1492            },
1493            || (),
1494        );
1495        assert!(refused.unwrap_err().contains("it will not run"));
1496        assert!(
1497            begun.elapsed() < Duration::from_secs(2),
1498            "{:?}",
1499            begun.elapsed()
1500        );
1501    }
1502
1503    /// With a short start budget a closure that is never picked up is abandoned at the budget
1504    /// (it never runs), not after the full 10 s; one that started is still waited for (grace).
1505    #[test]
1506    fn a_short_start_budget_abandons_an_unstarted_job_but_waits_for_a_started_one() {
1507        use super::serialized_round_trip;
1508        use std::time::Instant;
1509        let gate = leaked_gate();
1510        let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
1511        let r = Arc::clone(&ran);
1512        let begun = Instant::now();
1513        // `post` that never runs the job: the main thread is "busy".
1514        let parked: Arc<std::sync::Mutex<Vec<Box<dyn FnOnce() + Send>>>> = Arc::default();
1515        let park = Arc::clone(&parked);
1516        let refused = serialized_round_trip(
1517            gate,
1518            "never-started",
1519            Duration::from_millis(80),
1520            Duration::from_secs(5),
1521            move |job| {
1522                park.lock().unwrap().push(job);
1523                Ok(())
1524            },
1525            move || r.store(true, std::sync::atomic::Ordering::SeqCst),
1526        );
1527        assert!(refused.unwrap_err().contains("it will not run"));
1528        assert!(
1529            begun.elapsed() < Duration::from_secs(2),
1530            "{:?}",
1531            begun.elapsed()
1532        );
1533        // The main thread gets to it later: the abandoned job must not run.
1534        for job in parked.lock().unwrap().drain(..) {
1535            job();
1536        }
1537        assert!(!ran.load(std::sync::atomic::Ordering::SeqCst));
1538
1539        // A job that starts in time but runs past the start budget: its real outcome comes back.
1540        let (started_tx, started_rx) = std::sync::mpsc::channel();
1541        let slow = serialized_round_trip(
1542            gate,
1543            "started",
1544            Duration::from_millis(80),
1545            Duration::from_secs(5),
1546            post_started(started_rx),
1547            move || {
1548                let _ = started_tx.send(());
1549                std::thread::sleep(Duration::from_millis(200));
1550                "done"
1551            },
1552        );
1553        assert_eq!(slow.unwrap(), "done");
1554    }
1555
1556    /// R4-LOCK1: a round trip whose closure STARTED but outlived timeout + grace returned
1557    /// "outcome unknown" and released the dispatch lock while the closure was still running on
1558    /// the main thread — so the next caller put a second round trip in flight beside it, the
1559    /// exact condition `MAIN_DISPATCH_LOCK` exists to prevent (`WebKitGTK` heap corruption).
1560    #[test]
1561    fn a_job_outliving_its_caller_blocks_the_next_round_trip_until_it_finishes() {
1562        use super::serialized_round_trip;
1563        use std::time::Instant;
1564        let gate = leaked_gate();
1565        let first_end = Arc::new(std::sync::Mutex::new(None::<Instant>));
1566        let fe = Arc::clone(&first_end);
1567        // The first job is known to have started before its caller's deadline, and it runs
1568        // until released — no sleep decides either (both used to race a loaded scheduler).
1569        let (started_tx, started_rx) = std::sync::mpsc::channel();
1570        let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
1571        let first = serialized_round_trip(
1572            gate,
1573            "first",
1574            Duration::from_millis(50),
1575            Duration::from_millis(50),
1576            post_started(started_rx),
1577            move || {
1578                let _ = started_tx.send(());
1579                let _ = release_rx.recv();
1580                *fe.lock().unwrap() = Some(Instant::now());
1581            },
1582        );
1583        assert!(
1584            first.unwrap_err().contains("outcome is unknown"),
1585            "precondition: the first caller gave up while its job was running"
1586        );
1587        // Release the first job only once the second caller holds the dispatch lock — it is
1588        // then provably waiting on the still-running job, not merely about to start.
1589        let releaser = std::thread::spawn(move || {
1590            while gate.lock.try_lock().is_ok() {
1591                std::thread::sleep(Duration::from_millis(1));
1592            }
1593            std::thread::sleep(Duration::from_millis(20));
1594            release_tx.send(()).unwrap();
1595        });
1596        let second_start = serialized_round_trip(
1597            gate,
1598            "second",
1599            Duration::from_secs(5),
1600            Duration::from_secs(5),
1601            post_after(Duration::ZERO),
1602            Instant::now,
1603        )
1604        .expect("the second round trip runs once the first job is done");
1605        releaser.join().unwrap();
1606        let first_end = first_end
1607            .lock()
1608            .unwrap()
1609            .expect("the first job finished before the second started");
1610        assert!(
1611            second_start >= first_end,
1612            "second round trip started {:?} before the abandoned first job finished",
1613            first_end - second_start
1614        );
1615    }
1616
1617    /// The wait for an earlier caller's still-running job is bounded by the caller's OWN
1618    /// deadline (no N * timeout stacking), and a caller that gives up never runs its job.
1619    #[test]
1620    fn waiting_for_an_abandoned_job_is_bounded_by_the_callers_deadline() {
1621        use super::serialized_round_trip;
1622        let gate = leaked_gate();
1623        let (started_tx, started_rx) = std::sync::mpsc::channel();
1624        let first = serialized_round_trip(
1625            gate,
1626            "first",
1627            Duration::from_millis(30),
1628            Duration::from_millis(30),
1629            post_started(started_rx),
1630            move || {
1631                let _ = started_tx.send(());
1632                std::thread::sleep(Duration::from_millis(1500));
1633            },
1634        );
1635        assert!(first.unwrap_err().contains("outcome is unknown"));
1636        let ran = Arc::new(AtomicUsize::new(0));
1637        let r = Arc::clone(&ran);
1638        let started = std::time::Instant::now();
1639        let second = serialized_round_trip(
1640            gate,
1641            "second",
1642            Duration::from_millis(200),
1643            Duration::from_millis(200),
1644            post_after(Duration::ZERO),
1645            move || r.fetch_add(1, Ordering::SeqCst),
1646        );
1647        let waited = started.elapsed();
1648        let err = second.unwrap_err();
1649        assert!(err.contains("still running"), "{err}");
1650        assert!(waited < Duration::from_millis(1000), "waited {waited:?}");
1651        std::thread::sleep(Duration::from_millis(1600));
1652        assert_eq!(
1653            ran.load(Ordering::SeqCst),
1654            0,
1655            "a caller that gave up ran its job"
1656        );
1657    }
1658
1659    /// R4-IN2: a trusted click was only checked for landing on OUR top-level window — which
1660    /// includes its title bar and close button. It must land inside the client area.
1661    #[test]
1662    fn trusted_click_must_land_inside_the_client_area() {
1663        use super::client_point_verdict;
1664        assert!(client_point_verdict(0.0, 0.0, 800, 600).is_ok());
1665        assert!(client_point_verdict(799.9, 599.9, 800, 600).is_ok());
1666        for (x, y) in [
1667            (-1.0, 10.0),  // left of the client area (window border)
1668            (10.0, -30.0), // above it: the title bar / caption buttons
1669            (800.0, 10.0), // right edge is exclusive
1670            (10.0, 600.0),
1671            (f64::NAN, 10.0), // `NaN as i32` is 0: would silently click the corner
1672            (10.0, f64::INFINITY),
1673            (1e12, 10.0), // `as i32` saturates to i32::MAX
1674        ] {
1675            let err = client_point_verdict(x, y, 800, 600).unwrap_err();
1676            assert!(err.contains("outside"), "({x}, {y}): {err}");
1677        }
1678        assert!(
1679            client_point_verdict(0.0, 0.0, 0, 0).is_err(),
1680            "empty client area"
1681        );
1682    }
1683
1684    #[test]
1685    fn a_panicking_job_is_an_error_not_a_hang() {
1686        let out: Result<(), String> = dispatch_and_wait(
1687            "get_native_handle",
1688            Duration::from_secs(5),
1689            Duration::from_secs(5),
1690            post_after(Duration::ZERO),
1691            || panic!("boom"),
1692        );
1693        assert!(out.unwrap_err().contains("panicked"));
1694    }
1695
1696    /// A stand-in Tauri main thread: runs posted jobs in order, inline when posted from itself
1697    /// (exactly what tauri-runtime-wry's `send_user_message` does).
1698    struct FakeMainThread {
1699        jobs: std::sync::mpsc::Sender<Box<dyn FnOnce() + Send>>,
1700        id: std::thread::ThreadId,
1701    }
1702
1703    impl FakeMainThread {
1704        fn start() -> Self {
1705            let (jobs, rx) = std::sync::mpsc::channel::<Box<dyn FnOnce() + Send>>();
1706            let (id_tx, id_rx) = std::sync::mpsc::channel();
1707            std::thread::spawn(move || {
1708                id_tx.send(std::thread::current().id()).unwrap();
1709                while let Ok(job) = rx.recv() {
1710                    job();
1711                }
1712            });
1713            Self {
1714                jobs,
1715                id: id_rx.recv().unwrap(),
1716            }
1717        }
1718
1719        fn post(&self) -> impl Fn(Box<dyn FnOnce() + Send>) -> Result<(), String> + '_ {
1720            move |job| {
1721                if std::thread::current().id() == self.id {
1722                    job();
1723                    Ok(())
1724                } else {
1725                    self.jobs.send(job).map_err(|e| e.to_string())
1726                }
1727            }
1728        }
1729
1730        /// Run `f` on the fake main thread and wait for it.
1731        fn run<T: Send + 'static>(&self, f: impl FnOnce() -> T + Send + 'static) -> T {
1732            let (tx, rx) = std::sync::mpsc::channel();
1733            self.jobs
1734                .send(Box::new(move || tx.send(f()).unwrap()))
1735                .unwrap();
1736            rx.recv().unwrap()
1737        }
1738    }
1739
1740    type Adopted = Arc<std::sync::Mutex<Vec<(std::thread::ThreadId, String)>>>;
1741
1742    fn recording_adopt(log: &Adopted) -> impl Fn(&String) + Send + 'static {
1743        let log = Arc::clone(log);
1744        move |h: &String| {
1745            log.lock()
1746                .unwrap()
1747                .push((std::thread::current().id(), h.clone()));
1748        }
1749    }
1750
1751    /// R5B-MAIN1: Tauri lets an app register a plugin later from a background thread
1752    /// (`AppHandle::plugin`), and then the plugin's `setup` runs THERE. Recording that thread as
1753    /// "main" and installing the handle in its thread-local left every later `on_main` round
1754    /// trip failing with "the main-thread app handle is not installed". Adoption must happen on
1755    /// the real main thread.
1756    #[test]
1757    fn setup_off_the_main_thread_adopts_the_real_main_thread() {
1758        let main = FakeMainThread::start();
1759        let log: Adopted = Arc::default();
1760        let handle = "app".to_string();
1761        let outcome = super::adopt_on_main(&handle, main.post(), recording_adopt(&log));
1762        assert_eq!(outcome, Ok(super::MainThreadAdoption::Posted));
1763        // The posted adoption runs before anything posted after it.
1764        main.run(|| ());
1765        let adopted = log.lock().unwrap().clone();
1766        assert_eq!(adopted, vec![(main.id, "app".to_string())]);
1767    }
1768
1769    /// The normal case — setup on the main thread — adopts in place, before returning.
1770    #[test]
1771    fn setup_on_the_main_thread_adopts_inline() {
1772        let main = Arc::new(FakeMainThread::start());
1773        let log: Adopted = Arc::default();
1774        let (m, l) = (Arc::clone(&main), Arc::clone(&log));
1775        let outcome = main.run(move || {
1776            let outcome = super::adopt_on_main(&"app".to_string(), m.post(), recording_adopt(&l));
1777            // Adopted before `adopt_on_main` returned, not merely queued.
1778            let adopted_now = l.lock().unwrap().len();
1779            (outcome, adopted_now)
1780        });
1781        assert_eq!(outcome, (Ok(super::MainThreadAdoption::Inline), 1));
1782        assert_eq!(log.lock().unwrap()[0].0, main.id);
1783    }
1784
1785    #[test]
1786    fn main_thread_is_recorded_once_and_only_that_thread_matches() {
1787        // Record from a dedicated thread (standing in for Tauri's setup thread): only that
1788        // thread is "main"; every other thread — including this test's — is not.
1789        std::thread::spawn(|| {
1790            record_main_thread();
1791            assert!(is_main_thread(), "the recording thread is the main thread");
1792        })
1793        .join()
1794        .unwrap();
1795        assert!(
1796            !is_main_thread(),
1797            "a different thread must not be treated as main"
1798        );
1799        std::thread::spawn(|| assert!(!is_main_thread()))
1800            .join()
1801            .unwrap();
1802        // A second record from another thread must not move it (set-once).
1803        std::thread::spawn(record_main_thread).join().unwrap();
1804        assert!(!is_main_thread());
1805    }
1806}