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 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 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 tauri::RunEvent::Exit => {
150 if let Some(agent) = app.try_state::<Agent>() {
151 agent.cleanup_endpoint();
152 }
153 }
154 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}