Skip to main content

tauri_agent_plugin/
lib.rs

1use tauri::{plugin::TauriPlugin, Manager, Runtime};
2
3mod bridge;
4mod commands;
5mod endpoint;
6mod error;
7mod models;
8mod random;
9mod registry;
10mod screenshot;
11mod server;
12
13pub use endpoint::{
14    endpoint_registry_path, endpoint_runtime_dir, read_endpoint_registry, remove_endpoint_registry,
15    write_endpoint_registry, AgentEndpointDescriptor, EndpointRegistryError, VncEndpoint,
16};
17pub use error::Error;
18pub use models::{
19    AgentAction, AgentActionRequest, AgentAttachRequest, AgentAttachResponse, AgentBlurRequest,
20    AgentCheckRequest, AgentCookieEntry, AgentCookiesRequest, AgentCookiesResponse,
21    AgentDragRequest, AgentEvalRequest, AgentEventEntry, AgentEventsRequest, AgentExpectRequest,
22    AgentExpectResponse, AgentFindRequest, AgentFindResponse, AgentFocusRequest, AgentHoverRequest,
23    AgentIpcEntry, AgentIpcRequest, AgentLocationRequest, AgentLocationResponse, AgentLogEntry,
24    AgentLogRequest, AgentNetworkEntry, AgentNetworkRequest, AgentRecordEntry, AgentRecordRequest,
25    AgentRecordResponse, AgentScreenshotRequest, AgentScrollRequest, AgentSelectRequest,
26    AgentSnapshotRequest, AgentStateRequest, AgentStorageEntry, AgentStorageRequest,
27    AgentStorageResponse, AgentStreamFrame, AgentStreamRequest, AgentStreamResponse,
28    AgentTypeRequest, AgentWaitRequest, AgentWaitResponse, AgentWindowRequest, Config,
29    CookieAction, InlineServerConfig, KeyModifier, LocationAction, RecordAction, ScreenshotBackend,
30    SnapshotMode, StorageAction, StorageArea, WindowAction, WindowBounds, WindowInfo,
31};
32
33pub type Result<T> = std::result::Result<T, Error>;
34
35#[derive(Clone, Debug)]
36pub struct Agent {
37    config: Config,
38    endpoint: Option<AgentEndpointDescriptor>,
39    published: bool,
40}
41
42impl Agent {
43    pub fn config(&self) -> &Config {
44        &self.config
45    }
46
47    pub fn endpoint(&self) -> Option<&AgentEndpointDescriptor> {
48        self.endpoint.as_ref()
49    }
50
51    fn cleanup_endpoint(&self) {
52        // Only remove the registry if this instance actually published it, so we
53        // never delete a sibling instance's file.
54        if !self.published {
55            return;
56        }
57        if let Some(endpoint) = &self.endpoint {
58            let _ = remove_endpoint_registry(endpoint.app_id(), None);
59        }
60    }
61}
62
63pub trait AgentExt<R: Runtime> {
64    fn agent(&self) -> &Agent;
65}
66
67impl<R: Runtime, T: Manager<R>> AgentExt<R> for T {
68    fn agent(&self) -> &Agent {
69        self.state::<Agent>().inner()
70    }
71}
72
73fn validate_inline_server_config(config: &Config, debug_assertions: bool) -> Result<()> {
74    if !config.inline_server.enabled {
75        return Ok(());
76    }
77    if !debug_assertions && !config.allow_release_socket {
78        return Err(Error::BridgeUnavailable(
79            "inlineServer requires allowReleaseSocket in release builds".into(),
80        ));
81    }
82    if !config.allow_non_loopback && !host_is_loopback(&config.inline_server.host) {
83        return Err(Error::BridgeUnavailable(format!(
84            "inlineServer host {} is not loopback; set allowNonLoopback to bind it",
85            config.inline_server.host
86        )));
87    }
88    Ok(())
89}
90
91fn host_is_loopback(host: &str) -> bool {
92    if host.eq_ignore_ascii_case("localhost") {
93        return true;
94    }
95    host.parse::<std::net::IpAddr>()
96        .map(|ip| ip.is_loopback())
97        .unwrap_or(false)
98}
99
100pub struct Builder;
101
102impl Builder {
103    pub fn new() -> Self {
104        Self
105    }
106
107    pub fn build<R: Runtime>(self) -> TauriPlugin<R, Option<Config>> {
108        tauri::plugin::Builder::<R, Option<Config>>::new("agent")
109            .setup(|app, api| {
110                let config = api.config().clone().unwrap_or_default();
111                validate_inline_server_config(&config, cfg!(debug_assertions))?;
112                app.manage(bridge::AgentBridge::default());
113                app.manage(registry::WebviewRegistry::<R>::default());
114                let endpoint = if config.inline_server.enabled {
115                    let server = server::start_inline_debugger_server(
116                        app.clone(),
117                        app.config().identifier.clone(),
118                        &config.inline_server,
119                        config.vnc.clone(),
120                    )?;
121                    let descriptor = server.descriptor().clone();
122                    app.manage(server);
123                    Some(descriptor)
124                } else {
125                    None
126                };
127                let published =
128                    config.inline_server.enabled && config.inline_server.publish_endpoint;
129                app.manage(Agent {
130                    config,
131                    endpoint,
132                    published,
133                });
134                Ok(())
135            })
136            .on_webview_ready(|webview| {
137                // Track every webview ourselves, keyed by webview label. Tauri's
138                // `webview_windows()` evicts a window from its map as soon as a
139                // second webview joins it, which would drop the guest
140                // registration for the original webview (#39).
141                let window = webview.window();
142                webview
143                    .state::<registry::WebviewRegistry<R>>()
144                    .register(webview.label(), window);
145            })
146            .on_event(|app, event| match event {
147                // Clean up only on the real Exit; ExitRequested can be vetoed by
148                // the app, which would leave a still-running app undiscoverable.
149                tauri::RunEvent::Exit => {
150                    if let Some(agent) = app.try_state::<Agent>() {
151                        agent.cleanup_endpoint();
152                    }
153                }
154                // Evict registrations only for the exact window that died.
155                tauri::RunEvent::WindowEvent {
156                    label,
157                    event: tauri::WindowEvent::Destroyed,
158                    ..
159                } => {
160                    if let Some(registry) = app.try_state::<registry::WebviewRegistry<R>>() {
161                        registry.remove_window(label);
162                    }
163                }
164                _ => {}
165            })
166            .on_drop(|app| {
167                if let Some(agent) = app.try_state::<Agent>() {
168                    agent.cleanup_endpoint();
169                }
170            })
171            .invoke_handler(tauri::generate_handler![
172                commands::agent_bridge_response,
173                commands::agent_attach,
174                commands::agent_snapshot,
175                commands::agent_find,
176                commands::agent_action,
177                commands::agent_inspect,
178                commands::agent_eval,
179                commands::agent_type,
180                commands::agent_select,
181                commands::agent_check,
182                commands::agent_upload,
183                commands::agent_hover,
184                commands::agent_focus,
185                commands::agent_blur,
186                commands::agent_scroll,
187                commands::agent_drag,
188                commands::agent_screenshot,
189                commands::agent_logs,
190                commands::agent_events,
191                commands::agent_network,
192                commands::agent_ipc,
193                commands::agent_storage,
194                commands::agent_cookies,
195                commands::agent_location,
196                commands::agent_windows,
197                commands::agent_window,
198                commands::agent_wait,
199                commands::agent_expect,
200                commands::agent_state,
201                commands::agent_dialog,
202                commands::agent_record,
203                commands::agent_stream,
204            ])
205            .build()
206    }
207}
208
209impl Default for Builder {
210    fn default() -> Self {
211        Self::new()
212    }
213}
214
215pub fn init<R: Runtime>() -> TauriPlugin<R, Option<Config>> {
216    Builder::new().build()
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn inline_enabled_config(allow_release_socket: bool) -> Config {
224        Config {
225            allow_release_socket,
226            inline_server: InlineServerConfig {
227                enabled: true,
228                ..Default::default()
229            },
230            ..Default::default()
231        }
232    }
233
234    #[test]
235    fn release_build_rejects_inline_server_without_explicit_socket_opt_in() {
236        let error = validate_inline_server_config(&inline_enabled_config(false), false)
237            .expect_err("release inline server should require allow_release_socket");
238
239        assert!(error.to_string().contains("allowReleaseSocket"));
240    }
241
242    #[test]
243    fn release_build_allows_inline_server_with_explicit_socket_opt_in() {
244        validate_inline_server_config(&inline_enabled_config(true), false).unwrap();
245    }
246
247    #[test]
248    fn debug_build_allows_inline_server_without_release_socket_opt_in() {
249        validate_inline_server_config(&inline_enabled_config(false), true).unwrap();
250    }
251
252    #[test]
253    fn rejects_non_loopback_host_without_opt_in() {
254        let mut config = inline_enabled_config(true);
255        config.inline_server.host = "0.0.0.0".into();
256        let error = validate_inline_server_config(&config, true)
257            .expect_err("non-loopback host should require allow_non_loopback");
258        assert!(error.to_string().contains("allowNonLoopback"));
259    }
260
261    #[test]
262    fn allows_non_loopback_host_with_opt_in() {
263        let mut config = inline_enabled_config(true);
264        config.inline_server.host = "0.0.0.0".into();
265        config.allow_non_loopback = true;
266        validate_inline_server_config(&config, true).unwrap();
267    }
268
269    #[test]
270    fn loopback_hosts_are_recognized() {
271        assert!(host_is_loopback("127.0.0.1"));
272        assert!(host_is_loopback("::1"));
273        assert!(host_is_loopback("localhost"));
274        assert!(!host_is_loopback("0.0.0.0"));
275        assert!(!host_is_loopback("192.168.1.4"));
276    }
277}