1use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use futures_util::{SinkExt, StreamExt};
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10use tokio_tungstenite::tungstenite::Message;
11
12const DEFAULT_HOST: &str = "127.0.0.1";
13const DEFAULT_SCAN_RANGE: std::ops::RangeInclusive<u16> = 9555..=9655;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct ConnectorInstance {
18 pub pid: u32,
19 pub ws_port: u16,
20 pub mcp_port: Option<u16>,
21 pub bridge_port: Option<u16>,
22 pub app_name: Option<String>,
23 pub app_id: Option<String>,
24 pub log_dir: Option<PathBuf>,
25 pub exe: Option<PathBuf>,
26 pub started_at: Option<u64>,
27 #[serde(skip_deserializing)]
28 pub pid_file: PathBuf,
29}
30
31impl ConnectorInstance {
32 pub fn snapshots_dir(&self) -> PathBuf {
34 self.log_dir
35 .clone()
36 .unwrap_or_else(|| std::env::temp_dir().join(format!("tauri-connector-{}", self.pid)))
37 .join("snapshots")
38 }
39}
40
41#[derive(Debug, Clone)]
43pub struct ConnectionOptions {
44 pub cwd: PathBuf,
45 pub host: Option<String>,
46 pub port: Option<u16>,
47 pub app_id: Option<String>,
48 pub pid_file: Option<PathBuf>,
49}
50
51impl ConnectionOptions {
52 pub fn from_current_dir() -> Self {
53 Self {
54 cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
55 host: None,
56 port: None,
57 app_id: None,
58 pid_file: None,
59 }
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65#[serde(rename_all = "snake_case")]
66pub enum ConnectionSource {
67 Explicit,
68 Env,
69 PidFile,
70 PortScan,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ResolvedConnection {
76 pub host: String,
77 pub port: u16,
78 pub source: ConnectionSource,
79 pub instance: Option<ConnectorInstance>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct InstanceStatus {
85 pub instance: ConnectorInstance,
86 pub pid_alive: bool,
87 pub ws_reachable: bool,
88 pub stale: bool,
89 pub error: Option<String>,
90}
91
92pub async fn resolve_connection(opts: ConnectionOptions) -> Result<ResolvedConnection, String> {
94 let env_host = std::env::var("TAURI_CONNECTOR_HOST").ok();
95 let env_port = std::env::var("TAURI_CONNECTOR_PORT")
96 .ok()
97 .map(|p| {
98 p.parse::<u16>()
99 .map_err(|_| format!("Invalid TAURI_CONNECTOR_PORT={p}"))
100 })
101 .transpose()?;
102
103 if opts.port.is_some() || opts.host.is_some() {
104 let host = opts
105 .host
106 .or(env_host)
107 .unwrap_or_else(|| DEFAULT_HOST.to_string());
108 let port = opts.port.or(env_port).unwrap_or(9555);
109 return Ok(ResolvedConnection {
110 host,
111 port,
112 source: ConnectionSource::Explicit,
113 instance: None,
114 });
115 }
116
117 if let Some(port) = env_port {
118 return Ok(ResolvedConnection {
119 host: env_host.unwrap_or_else(|| DEFAULT_HOST.to_string()),
120 port,
121 source: ConnectionSource::Env,
122 instance: None,
123 });
124 }
125
126 let host = env_host.unwrap_or_else(|| DEFAULT_HOST.to_string());
127 let app_id = opts
128 .app_id
129 .or_else(|| std::env::var("TAURI_CONNECTOR_APP_ID").ok());
130
131 let instances = discover_instances(&opts.cwd, app_id.as_deref(), opts.pid_file.as_deref());
132 let mut live = Vec::new();
133 let mut stale = Vec::new();
134 for instance in instances {
135 if !pid_is_alive(instance.pid) {
136 stale.push(format!(
137 "{} (pid {} is not running)",
138 instance.pid_file.display(),
139 instance.pid
140 ));
141 continue;
142 }
143 match ping_ws(&host, instance.ws_port, 1_500).await {
144 Ok(()) => live.push(instance),
145 Err(e) => stale.push(format!(
146 "{} (ws_port {} not reachable: {e})",
147 instance.pid_file.display(),
148 instance.ws_port
149 )),
150 }
151 }
152
153 if !live.is_empty() {
154 live.sort_by_key(|i| std::cmp::Reverse(i.started_at.unwrap_or(0)));
155 let instance = live.remove(0);
156 return Ok(ResolvedConnection {
157 host,
158 port: instance.ws_port,
159 source: ConnectionSource::PidFile,
160 instance: Some(instance),
161 });
162 }
163
164 for port in DEFAULT_SCAN_RANGE {
165 if ping_ws(&host, port, 250).await.is_ok() {
166 return Ok(ResolvedConnection {
167 host,
168 port,
169 source: ConnectionSource::PortScan,
170 instance: None,
171 });
172 }
173 }
174
175 let stale_hint = if stale.is_empty() {
176 String::new()
177 } else {
178 format!("\nStale connector files:\n- {}", stale.join("\n- "))
179 };
180 Err(format!(
181 "No running tauri-connector instance found. Start the Tauri app, pass --host/--port, set TAURI_CONNECTOR_PORT, or remove stale .connector.json files.{stale_hint}"
182 ))
183}
184
185pub async fn instance_statuses(
187 cwd: &Path,
188 app_id: Option<&str>,
189 pid_file: Option<&Path>,
190 host: Option<&str>,
191) -> Vec<InstanceStatus> {
192 let host = host.unwrap_or(DEFAULT_HOST);
193 let instances = discover_instances(cwd, app_id, pid_file);
194 let mut statuses = Vec::with_capacity(instances.len());
195 for instance in instances {
196 let pid_alive = pid_is_alive(instance.pid);
197 let (ws_reachable, error) = if pid_alive {
198 match ping_ws(host, instance.ws_port, 1_000).await {
199 Ok(()) => (true, None),
200 Err(e) => (false, Some(e)),
201 }
202 } else {
203 (false, Some("process is not running".to_string()))
204 };
205 statuses.push(InstanceStatus {
206 instance,
207 pid_alive,
208 ws_reachable,
209 stale: !pid_alive || !ws_reachable,
210 error,
211 });
212 }
213 statuses.sort_by_key(|s| std::cmp::Reverse(s.instance.started_at.unwrap_or(0)));
214 statuses
215}
216
217pub fn discover_instances(
219 cwd: &Path,
220 app_id: Option<&str>,
221 pid_file: Option<&Path>,
222) -> Vec<ConnectorInstance> {
223 let mut paths = Vec::new();
224 if let Some(p) = pid_file {
225 paths.push(p.to_path_buf());
226 }
227 if let Ok(p) = std::env::var("TAURI_CONNECTOR_PID_FILE") {
228 paths.push(PathBuf::from(p));
229 }
230 paths.extend(pid_file_candidates(cwd));
231
232 let mut seen = HashSet::new();
233 let mut instances = Vec::new();
234 for path in paths {
235 let key = path.canonicalize().unwrap_or(path.clone());
236 if !seen.insert(key) {
237 continue;
238 }
239 let Some(instance) = read_instance_file(&path) else {
240 continue;
241 };
242 if app_id.is_some_and(|id| instance.app_id.as_deref() != Some(id)) {
243 continue;
244 }
245 instances.push(instance);
246 }
247 instances
248}
249
250pub fn pid_file_candidates(cwd: &Path) -> Vec<PathBuf> {
252 let mut candidates = Vec::new();
253 for root in cwd.ancestors().take(8) {
254 candidates.extend([
255 root.join("src-tauri/target/.connector.json"),
256 root.join("src-tauri/target/debug/.connector.json"),
257 root.join("src-tauri/target/release/.connector.json"),
258 root.join("target/.connector.json"),
259 root.join("target/debug/.connector.json"),
260 root.join("target/release/.connector.json"),
261 ]);
262 }
263 candidates
264}
265
266fn read_instance_file(path: &Path) -> Option<ConnectorInstance> {
267 #[derive(Deserialize)]
268 struct RawInstance {
269 pid: u32,
270 ws_port: u16,
271 #[serde(default)]
272 mcp_port: Option<u16>,
273 #[serde(default)]
274 bridge_port: Option<u16>,
275 #[serde(default)]
276 app_name: Option<String>,
277 #[serde(default)]
278 app_id: Option<String>,
279 #[serde(default)]
280 log_dir: Option<PathBuf>,
281 #[serde(default)]
282 exe: Option<PathBuf>,
283 #[serde(default)]
284 started_at: Option<u64>,
285 }
286
287 let raw: RawInstance = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
288 Some(ConnectorInstance {
289 pid: raw.pid,
290 ws_port: raw.ws_port,
291 mcp_port: raw.mcp_port,
292 bridge_port: raw.bridge_port,
293 app_name: raw.app_name,
294 app_id: raw.app_id,
295 log_dir: raw.log_dir,
296 exe: raw.exe,
297 started_at: raw.started_at,
298 pid_file: path.to_path_buf(),
299 })
300}
301
302pub async fn ping_ws(host: &str, port: u16, timeout_ms: u64) -> Result<(), String> {
304 let url = format!("ws://{host}:{port}");
305 let connect = tokio_tungstenite::connect_async(&url);
306 let (mut ws, _) = tokio::time::timeout(Duration::from_millis(timeout_ms), connect)
307 .await
308 .map_err(|_| "connect timed out".to_string())?
309 .map_err(|e| format!("connect failed: {e}"))?;
310
311 let payload = json!({ "id": "discovery-ping", "type": "ping" }).to_string();
312 ws.send(Message::Text(payload.into()))
313 .await
314 .map_err(|e| format!("ping send failed: {e}"))?;
315
316 let next = tokio::time::timeout(Duration::from_millis(timeout_ms), ws.next())
317 .await
318 .map_err(|_| "ping timed out".to_string())?;
319 let Some(Ok(Message::Text(text))) = next else {
320 return Err("ping returned no text response".to_string());
321 };
322 let value: serde_json::Value =
323 serde_json::from_str(text.as_ref()).map_err(|e| format!("invalid ping JSON: {e}"))?;
324 if value.get("result").and_then(|v| v.as_str()) == Some("pong") {
325 Ok(())
326 } else {
327 Err("ping did not return pong".to_string())
328 }
329}
330
331#[cfg(unix)]
332fn pid_is_alive(pid: u32) -> bool {
333 unsafe extern "C" {
334 fn kill(pid: i32, sig: i32) -> i32;
335 }
336 unsafe { kill(pid as i32, 0) == 0 }
337}
338
339#[cfg(not(unix))]
340fn pid_is_alive(_pid: u32) -> bool {
341 true
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn candidates_include_documented_locations() {
350 let cwd = Path::new("/tmp/example/app");
351 let candidates = pid_file_candidates(cwd);
352 assert!(candidates
353 .iter()
354 .any(|p| p.ends_with("src-tauri/target/.connector.json")));
355 assert!(candidates
356 .iter()
357 .any(|p| p.ends_with("target/debug/.connector.json")));
358 }
359
360 #[test]
361 fn instance_snapshot_dir_prefers_log_dir() {
362 let instance = ConnectorInstance {
363 pid: 42,
364 ws_port: 9555,
365 mcp_port: None,
366 bridge_port: None,
367 app_name: None,
368 app_id: None,
369 log_dir: Some(PathBuf::from("/tmp/logs")),
370 exe: None,
371 started_at: None,
372 pid_file: PathBuf::from("/tmp/.connector.json"),
373 };
374 assert_eq!(
375 instance.snapshots_dir(),
376 PathBuf::from("/tmp/logs/snapshots")
377 );
378 }
379}