Skip to main content

victauri_plugin/
bridge.rs

1use tauri::{Manager, Runtime};
2use victauri_core::WindowState;
3
4/// Runtime-erased interface for webview and backend access, allowing the MCP
5/// server to interact with Tauri windows and the application backend without
6/// generic parameters.
7pub trait WebviewBridge: Send + Sync {
8    /// Execute JavaScript in the target webview (defaults to "main" or first visible window).
9    ///
10    /// # Errors
11    ///
12    /// Returns an error string if no matching window is found or the eval fails.
13    fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String>;
14    /// Retrieve the state of one or all windows (position, size, visibility, focus, URL).
15    fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState>;
16    /// Return the labels of all open webview windows.
17    fn list_window_labels(&self) -> Vec<String>;
18    /// Return the platform-native window handle for screenshot capture.
19    /// Windows: `HWND`, macOS: `CGWindowID` (window number), Linux: `X11` window ID.
20    ///
21    /// # Errors
22    ///
23    /// Returns an error string if no matching window is found or the handle type is unsupported.
24    fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String>;
25    /// Perform a window management action (minimize, maximize, close, show, hide, etc.).
26    ///
27    /// # Errors
28    ///
29    /// Returns an error string if no matching window is found or the action fails.
30    fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String>;
31    /// Set the logical size of a window in device-independent pixels.
32    ///
33    /// # Errors
34    ///
35    /// Returns an error string if no matching window is found or the resize fails.
36    fn resize_window(&self, label: Option<&str>, width: u32, height: u32) -> Result<(), String>;
37    /// Set the logical position of a window in device-independent pixels.
38    ///
39    /// # Errors
40    ///
41    /// Returns an error string if no matching window is found or the move fails.
42    fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String>;
43    /// Set the title bar text of a window.
44    ///
45    /// # Errors
46    ///
47    /// Returns an error string if no matching window is found or the title change fails.
48    fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String>;
49
50    // ── Backend Access ─────────────────────────────────────────────────────
51
52    /// Return the app's per-user data directory (e.g. `~/.local/share/<app>/`).
53    ///
54    /// # Errors
55    ///
56    /// Returns an error if the path cannot be resolved.
57    fn app_data_dir(&self) -> Result<std::path::PathBuf, String> {
58        Err("backend access not available".to_string())
59    }
60
61    /// Return the app's per-user config directory (e.g. `~/.config/<app>/`).
62    ///
63    /// # Errors
64    ///
65    /// Returns an error if the path cannot be resolved.
66    fn app_config_dir(&self) -> Result<std::path::PathBuf, String> {
67        Err("backend access not available".to_string())
68    }
69
70    /// Return the app's log directory.
71    ///
72    /// # Errors
73    ///
74    /// Returns an error if the path cannot be resolved.
75    fn app_log_dir(&self) -> Result<std::path::PathBuf, String> {
76        Err("backend access not available".to_string())
77    }
78
79    /// Return the app's local data directory.
80    ///
81    /// # Errors
82    ///
83    /// Returns an error if the path cannot be resolved.
84    fn app_local_data_dir(&self) -> Result<std::path::PathBuf, String> {
85        Err("backend access not available".to_string())
86    }
87
88    /// Return the Tauri app configuration as JSON.
89    #[must_use]
90    fn tauri_config(&self) -> serde_json::Value {
91        serde_json::Value::Null
92    }
93}
94
95fn find_window<'a, R: Runtime>(
96    windows: &'a std::collections::HashMap<String, tauri::WebviewWindow<R>>,
97    label: Option<&str>,
98) -> Result<&'a tauri::WebviewWindow<R>, String> {
99    match label {
100        Some(l) => windows
101            .get(l)
102            .ok_or_else(|| format!("window not found: {l}")),
103        None => windows
104            .get("main")
105            .or_else(|| windows.values().find(|w| w.is_visible().unwrap_or(false)))
106            .or_else(|| windows.values().next())
107            .ok_or_else(|| "no window available".to_string()),
108    }
109}
110
111impl<R: Runtime> WebviewBridge for tauri::AppHandle<R> {
112    fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String> {
113        let windows = self.webview_windows();
114        let webview = find_window(&windows, label)?;
115        webview.eval(script).map_err(|e| e.to_string())
116    }
117
118    fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
119        let windows = self.webview_windows();
120        let mut states = Vec::new();
121
122        for (win_label, window) in &windows {
123            if let Some(filter) = label
124                && win_label != filter
125            {
126                continue;
127            }
128
129            let pos = window.outer_position().unwrap_or_default();
130            let size = window.inner_size().unwrap_or_default();
131
132            states.push(WindowState {
133                label: win_label.clone(),
134                title: window.title().unwrap_or_default(),
135                url: window.url().map(|u| u.to_string()).unwrap_or_default(),
136                visible: window.is_visible().unwrap_or(false),
137                focused: window.is_focused().unwrap_or(false),
138                maximized: window.is_maximized().unwrap_or(false),
139                minimized: window.is_minimized().unwrap_or(false),
140                fullscreen: window.is_fullscreen().unwrap_or(false),
141                position: (pos.x, pos.y),
142                size: (size.width, size.height),
143            });
144        }
145
146        states
147    }
148
149    fn list_window_labels(&self) -> Vec<String> {
150        self.webview_windows().keys().cloned().collect()
151    }
152
153    fn get_native_handle(&self, label: Option<&str>) -> Result<isize, String> {
154        use raw_window_handle::{HasWindowHandle, RawWindowHandle};
155
156        let windows = self.webview_windows();
157        let _webview = find_window(&windows, label)?;
158        let handle = _webview.window_handle().map_err(|e| e.to_string())?;
159        match handle.as_raw() {
160            #[cfg(windows)]
161            RawWindowHandle::Win32(h) => Ok(h.hwnd.get()),
162            #[cfg(target_os = "macos")]
163            RawWindowHandle::AppKit(h) => {
164                // CGWindowListCreateImage needs CGWindowID (the window number),
165                // not the NSView pointer. Extract via Objective-C runtime.
166                macos_window_number(h.ns_view.as_ptr())
167            }
168            #[cfg(target_os = "linux")]
169            RawWindowHandle::Xlib(h) => Ok(h.window as isize),
170            #[cfg(target_os = "linux")]
171            RawWindowHandle::Xcb(h) => Ok(h.window.get() as isize),
172            _ => Err("unsupported window handle type on this platform".to_string()),
173        }
174    }
175
176    fn manage_window(&self, label: Option<&str>, action: &str) -> Result<String, String> {
177        let windows = self.webview_windows();
178        let window = find_window(&windows, label)?;
179
180        match action {
181            "minimize" => window.minimize().map_err(|e| e.to_string())?,
182            "unminimize" => window.unminimize().map_err(|e| e.to_string())?,
183            "maximize" => window.maximize().map_err(|e| e.to_string())?,
184            "unmaximize" => window.unmaximize().map_err(|e| e.to_string())?,
185            "close" => window.close().map_err(|e| e.to_string())?,
186            "focus" => window.set_focus().map_err(|e| e.to_string())?,
187            "show" => window.show().map_err(|e| e.to_string())?,
188            "hide" => window.hide().map_err(|e| e.to_string())?,
189            "fullscreen" => window.set_fullscreen(true).map_err(|e| e.to_string())?,
190            "unfullscreen" => window.set_fullscreen(false).map_err(|e| e.to_string())?,
191            "always_on_top" => window.set_always_on_top(true).map_err(|e| e.to_string())?,
192            "not_always_on_top" => window.set_always_on_top(false).map_err(|e| e.to_string())?,
193            _ => return Err(format!("unknown action: {action}")),
194        }
195
196        Ok(format!("{action} executed"))
197    }
198
199    fn resize_window(&self, label: Option<&str>, width: u32, height: u32) -> Result<(), String> {
200        let windows = self.webview_windows();
201        let window = find_window(&windows, label)?;
202
203        window
204            .set_size(tauri::LogicalSize::new(width, height))
205            .map_err(|e| e.to_string())
206    }
207
208    fn move_window(&self, label: Option<&str>, x: i32, y: i32) -> Result<(), String> {
209        let windows = self.webview_windows();
210        let window = find_window(&windows, label)?;
211
212        window
213            .set_position(tauri::LogicalPosition::new(x, y))
214            .map_err(|e| e.to_string())
215    }
216
217    fn set_window_title(&self, label: Option<&str>, title: &str) -> Result<(), String> {
218        let windows = self.webview_windows();
219        let window = find_window(&windows, label)?;
220
221        window.set_title(title).map_err(|e| e.to_string())
222    }
223
224    fn app_data_dir(&self) -> Result<std::path::PathBuf, String> {
225        self.path().app_data_dir().map_err(|e| e.to_string())
226    }
227
228    fn app_config_dir(&self) -> Result<std::path::PathBuf, String> {
229        self.path().app_config_dir().map_err(|e| e.to_string())
230    }
231
232    fn app_log_dir(&self) -> Result<std::path::PathBuf, String> {
233        self.path().app_log_dir().map_err(|e| e.to_string())
234    }
235
236    fn app_local_data_dir(&self) -> Result<std::path::PathBuf, String> {
237        self.path().app_local_data_dir().map_err(|e| e.to_string())
238    }
239
240    fn tauri_config(&self) -> serde_json::Value {
241        let config = self.config();
242
243        let windows: Vec<serde_json::Value> = config
244            .app
245            .windows
246            .iter()
247            .map(|w| {
248                serde_json::json!({
249                    "label": w.label,
250                    "title": w.title,
251                    "url": format!("{}", w.url),
252                    "width": w.width,
253                    "height": w.height,
254                    "visible": w.visible,
255                    "resizable": w.resizable,
256                    "fullscreen": w.fullscreen,
257                    "decorations": w.decorations,
258                    "transparent": w.transparent,
259                    "always_on_top": w.always_on_top,
260                })
261            })
262            .collect();
263
264        let plugins: Vec<String> = config.plugins.0.keys().cloned().collect();
265
266        let security = serde_json::json!({
267            "csp": config.app.security.csp.as_ref().map(|c| format!("{c}")),
268            "freeze_prototype": config.app.security.freeze_prototype,
269            "capabilities": config.app.security.capabilities.iter().map(|c| {
270                match c {
271                    tauri::utils::config::CapabilityEntry::Inlined(cap) => {
272                        serde_json::json!({
273                            "identifier": cap.identifier,
274                            "description": cap.description,
275                            "windows": cap.windows,
276                            "webviews": cap.webviews,
277                            "permissions": cap.permissions.iter().map(|p| format!("{p:?}")).collect::<Vec<_>>(),
278                            "platforms": cap.platforms,
279                        })
280                    }
281                    tauri::utils::config::CapabilityEntry::Reference(path) => {
282                        serde_json::json!({ "reference": path })
283                    }
284                }
285            }).collect::<Vec<_>>(),
286        });
287
288        serde_json::json!({
289            "identifier": config.identifier,
290            "product_name": config.product_name,
291            "version": config.version,
292            "windows": windows,
293            "plugins": plugins,
294            "security": security,
295        })
296    }
297}
298
299#[cfg(target_os = "macos")]
300#[allow(unsafe_code)]
301fn macos_window_number(ns_view: *mut std::ffi::c_void) -> Result<isize, String> {
302    unsafe extern "C" {
303        fn objc_msgSend(obj: *mut std::ffi::c_void, sel: *mut std::ffi::c_void) -> isize;
304        fn sel_registerName(name: *const std::ffi::c_char) -> *mut std::ffi::c_void;
305    }
306
307    if ns_view.is_null() {
308        return Err("null NSView handle".to_string());
309    }
310
311    // SAFETY: `ns_view` is a valid NSView pointer obtained from Tauri's
312    // `with_webview` callback; null was checked above. `objc_msgSend` and
313    // `sel_registerName` are stable Objective-C runtime ABI.
314    unsafe {
315        let sel_window = sel_registerName(c"window".as_ptr());
316        let ns_window = objc_msgSend(ns_view, sel_window);
317        if ns_window == 0 {
318            return Err("NSView has no parent NSWindow".to_string());
319        }
320        let sel_window_number = sel_registerName(c"windowNumber".as_ptr());
321        let ns_window_ptr = ns_window as *mut std::ffi::c_void;
322        let window_number = objc_msgSend(ns_window_ptr, sel_window_number);
323        if window_number <= 0 {
324            return Err(format!("invalid CGWindowID: {window_number}"));
325        }
326        Ok(window_number)
327    }
328}