1use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use crate::identity::{workspace_matches, AppIdentity};
8use futures_util::{SinkExt, StreamExt};
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use tokio_tungstenite::tungstenite::Message;
12
13const DEFAULT_HOST: &str = "127.0.0.1";
14const DEFAULT_SCAN_RANGE: std::ops::RangeInclusive<u16> = 9555..=9655;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct ConnectorInstance {
19 pub pid: u32,
20 pub ws_port: u16,
21 pub mcp_port: Option<u16>,
22 pub bridge_port: Option<u16>,
23 pub app_name: Option<String>,
24 pub app_id: Option<String>,
25 pub app_instance_id: Option<String>,
26 pub log_dir: Option<PathBuf>,
27 pub exe: Option<PathBuf>,
28 pub started_at: Option<u64>,
29 #[serde(skip_deserializing)]
30 pub pid_file: PathBuf,
31}
32
33impl ConnectorInstance {
34 pub fn snapshots_dir(&self) -> PathBuf {
36 self.log_dir
37 .clone()
38 .unwrap_or_else(|| std::env::temp_dir().join(format!("tauri-connector-{}", self.pid)))
39 .join("snapshots")
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct ConnectionOptions {
46 pub cwd: PathBuf,
47 pub host: Option<String>,
48 pub port: Option<u16>,
49 pub app_id: Option<String>,
50 pub app_instance_id: Option<String>,
51 pub pid_file: Option<PathBuf>,
52}
53
54impl ConnectionOptions {
55 pub fn from_current_dir() -> Self {
56 Self {
57 cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
58 host: None,
59 port: None,
60 app_id: None,
61 app_instance_id: None,
62 pid_file: None,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(rename_all = "snake_case")]
70pub enum ConnectionSource {
71 Explicit,
72 Env,
73 PidFile,
74 PortScan,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ResolvedConnection {
80 pub host: String,
81 pub port: u16,
82 pub source: ConnectionSource,
83 pub instance: Option<ConnectorInstance>,
84 pub identity: Option<AppIdentity>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct InstanceStatus {
90 pub instance: ConnectorInstance,
91 pub pid_alive: bool,
92 pub ws_reachable: bool,
93 pub stale: bool,
94 pub error: Option<String>,
95}
96
97pub async fn resolve_connection(opts: ConnectionOptions) -> Result<ResolvedConnection, String> {
99 let env_host = std::env::var("TAURI_CONNECTOR_HOST").ok();
100 let env_port = std::env::var("TAURI_CONNECTOR_PORT")
101 .ok()
102 .map(|p| {
103 p.parse::<u16>()
104 .map_err(|_| "invalid_arguments: invalid TAURI_CONNECTOR_PORT".to_string())
105 })
106 .transpose()?;
107 let host = opts
108 .host
109 .clone()
110 .or(env_host)
111 .unwrap_or_else(|| DEFAULT_HOST.into());
112 let app_id = opts
113 .app_id
114 .clone()
115 .or_else(|| std::env::var("TAURI_CONNECTOR_APP_ID").ok());
116 let expected_instance = opts
117 .app_instance_id
118 .clone()
119 .or_else(|| std::env::var("TAURI_CONNECTOR_APP_INSTANCE_ID").ok());
120 for identifier in [app_id.as_deref(), expected_instance.as_deref()]
121 .into_iter()
122 .flatten()
123 {
124 if identifier.is_empty()
125 || identifier.len() > 256
126 || identifier.chars().any(char::is_control)
127 {
128 return Err("invalid_arguments: malformed application identifier".into());
129 }
130 }
131 let pid_file = opts.pid_file.clone().or_else(|| {
132 std::env::var("TAURI_CONNECTOR_PID_FILE")
133 .ok()
134 .map(PathBuf::from)
135 });
136 let explicit_endpoint = opts.port.is_some() || opts.host.is_some() || env_port.is_some();
137 if explicit_endpoint {
138 let port = opts.port.or(env_port).unwrap_or(9555);
139 if port == 0 {
140 return Err("invalid_arguments: port must be nonzero".into());
141 }
142 let identity = endpoint_identity(&host, port, 1500).await;
143 let identity = match identity {
144 Ok(identity) => Some(identity),
145 Err(error) if app_id.is_some() || expected_instance.is_some() || pid_file.is_some() => {
146 return Err(error)
147 }
148 Err(_) => {
149 ping_ws(&host, port, 1500).await?;
150 None
151 }
152 };
153 if let Some(ref identity) = identity {
154 validate_identity(
155 identity,
156 app_id.as_deref(),
157 expected_instance.as_deref(),
158 None,
159 )?;
160 if let Some(path) = pid_file.as_deref() {
161 let hint = read_instance_file(path)
162 .ok_or("app_not_found: explicit PID file unavailable")?;
163 validate_identity(
164 identity,
165 app_id.as_deref(),
166 expected_instance.as_deref(),
167 Some(&hint),
168 )?;
169 }
170 }
171 return Ok(ResolvedConnection {
172 host,
173 port,
174 source: if opts.host.is_some() || opts.port.is_some() {
175 ConnectionSource::Explicit
176 } else {
177 ConnectionSource::Env
178 },
179 instance: None,
180 identity,
181 });
182 }
183 let hints = discover_instances(&opts.cwd, app_id.as_deref(), pid_file.as_deref());
184 let mut candidates = Vec::new();
185 let mut endpoints = HashSet::new();
186 for hint in hints {
187 if !pid_is_alive(hint.pid) || !endpoints.insert((host.clone(), hint.ws_port)) {
188 continue;
189 }
190 if let Ok(identity) = endpoint_identity(&host, hint.ws_port, 1500).await {
191 if validate_identity(
192 &identity,
193 app_id.as_deref(),
194 expected_instance.as_deref(),
195 Some(&hint),
196 )
197 .is_ok()
198 && (app_id.is_some()
199 || expected_instance.is_some()
200 || pid_file.is_some()
201 || identity
202 .workspace_path
203 .as_deref()
204 .is_some_and(|path| workspace_matches(&opts.cwd, path)))
205 {
206 candidates.push(ResolvedConnection {
207 host: host.clone(),
208 port: hint.ws_port,
209 source: ConnectionSource::PidFile,
210 instance: Some(hint),
211 identity: Some(identity),
212 });
213 }
214 }
215 }
216 if pid_file.is_none() {
217 use futures_util::stream;
218 let results = stream::iter(
219 DEFAULT_SCAN_RANGE.filter(|port| !endpoints.contains(&(host.clone(), *port))),
220 )
221 .map(|port| {
222 let host = host.clone();
223 async move { (port, endpoint_identity(&host, port, 250).await) }
224 })
225 .buffer_unordered(12)
226 .collect::<Vec<_>>()
227 .await;
228 for (port, result) in results {
229 if let Ok(identity) = result {
230 if validate_identity(
231 &identity,
232 app_id.as_deref(),
233 expected_instance.as_deref(),
234 None,
235 )
236 .is_ok()
237 && (app_id.is_some()
238 || expected_instance.is_some()
239 || identity
240 .workspace_path
241 .as_deref()
242 .is_some_and(|path| workspace_matches(&opts.cwd, path)))
243 {
244 candidates.push(ResolvedConnection {
245 host: host.clone(),
246 port,
247 source: ConnectionSource::PortScan,
248 instance: None,
249 identity: Some(identity),
250 });
251 }
252 }
253 }
254 }
255 select_unique(candidates)
256}
257
258pub fn select_unique(
259 mut candidates: Vec<ResolvedConnection>,
260) -> Result<ResolvedConnection, String> {
261 match candidates.len() {
262 0=>Err("app_not_found: no endpoint satisfies the requested identity; no default-instance fallback was attempted".into()),
263 1=>Ok(candidates.remove(0)),
264 _=>{
265 let summaries=candidates.iter().map(|candidate|json!({"host":candidate.host,"port":candidate.port,"appId":candidate.identity.as_ref().map(|identity|&identity.app_id),"appInstanceId":candidate.identity.as_ref().map(|identity|&identity.app_instance_id)})).collect::<Vec<_>>();
266 Err(format!("ambiguous_app: {} verified candidates {}; specify --app-instance-id or --host/--port",candidates.len(),json!(summaries)))
267 },
268 }
269}
270
271pub fn validate_identity(
272 identity: &AppIdentity,
273 app_id: Option<&str>,
274 instance_id: Option<&str>,
275 hint: Option<&ConnectorInstance>,
276) -> Result<(), String> {
277 if app_id.is_some_and(|id| identity.app_id != id)
278 || instance_id.is_some_and(|id| identity.app_instance_id != id)
279 {
280 return Err("app_identity_mismatch: endpoint does not match requested app".into());
281 }
282 if let Some(hint) = hint {
283 if hint.pid != identity.pid
284 || hint
285 .started_at
286 .is_some_and(|start| start != identity.started_at)
287 || hint
288 .app_id
289 .as_deref()
290 .is_some_and(|id| id != identity.app_id)
291 || hint
292 .app_instance_id
293 .as_deref()
294 .is_some_and(|id| id != identity.app_instance_id)
295 {
296 return Err("app_identity_mismatch: stale PID file or reused endpoint/process".into());
297 }
298 }
299 Ok(())
300}
301
302pub async fn endpoint_identity(
303 host: &str,
304 port: u16,
305 timeout_ms: u64,
306) -> Result<AppIdentity, String> {
307 tokio::time::timeout(Duration::from_millis(timeout_ms), async {
308 let mut client = crate::ConnectorClient::new();
309 client.connect(host, port).await?;
310 let mut args = json!({});
311 if let Ok(token) = std::env::var("TAURI_CONNECTOR_WORKFLOW_TOKEN") {
312 args["authToken"] = json!(token);
313 }
314 AppIdentity::parse(
315 client
316 .send_with_timeout(
317 json!({"type":"inspection","operation":"app_identity","args":args}),
318 timeout_ms,
319 )
320 .await?,
321 )
322 })
323 .await
324 .map_err(|_| "identity_unavailable: handshake timed out".to_string())?
325}
326
327pub async fn instance_statuses(
329 cwd: &Path,
330 app_id: Option<&str>,
331 pid_file: Option<&Path>,
332 host: Option<&str>,
333) -> Vec<InstanceStatus> {
334 let host = host.unwrap_or(DEFAULT_HOST);
335 let instances = discover_instances(cwd, app_id, pid_file);
336 let mut statuses = Vec::with_capacity(instances.len());
337 for instance in instances {
338 let pid_alive = pid_is_alive(instance.pid);
339 let (ws_reachable, error) = if pid_alive {
340 match endpoint_identity(host, instance.ws_port, 1_000)
341 .await
342 .and_then(|identity| validate_identity(&identity, app_id, None, Some(&instance)))
343 {
344 Ok(()) => (true, None),
345 Err(e) => (false, Some(e)),
346 }
347 } else {
348 (false, Some("process is not running".to_string()))
349 };
350 statuses.push(InstanceStatus {
351 instance,
352 pid_alive,
353 ws_reachable,
354 stale: !pid_alive || !ws_reachable,
355 error,
356 });
357 }
358 statuses.sort_by_key(|s| std::cmp::Reverse(s.instance.started_at.unwrap_or(0)));
359 statuses
360}
361
362pub fn discover_instances(
364 cwd: &Path,
365 app_id: Option<&str>,
366 pid_file: Option<&Path>,
367) -> Vec<ConnectorInstance> {
368 let paths = if let Some(p) = pid_file {
369 vec![p.to_path_buf()]
370 } else if let Ok(p) = std::env::var("TAURI_CONNECTOR_PID_FILE") {
371 vec![PathBuf::from(p)]
372 } else {
373 pid_file_candidates(cwd)
374 };
375
376 let mut seen = HashSet::new();
377 let mut instances = Vec::new();
378 for path in paths {
379 let key = path.canonicalize().unwrap_or(path.clone());
380 if !seen.insert(key) {
381 continue;
382 }
383 let Some(instance) = read_instance_file(&path) else {
384 continue;
385 };
386 if app_id.is_some_and(|id| instance.app_id.as_deref() != Some(id)) {
387 continue;
388 }
389 instances.push(instance);
390 }
391 instances
392}
393
394pub fn pid_file_candidates(cwd: &Path) -> Vec<PathBuf> {
396 let mut candidates = Vec::new();
397 for root in cwd.ancestors().take(8) {
398 candidates.extend([
399 root.join("src-tauri/target/.connector.json"),
400 root.join("src-tauri/target/debug/.connector.json"),
401 root.join("src-tauri/target/release/.connector.json"),
402 root.join("target/.connector.json"),
403 root.join("target/debug/.connector.json"),
404 root.join("target/release/.connector.json"),
405 ]);
406 }
407 candidates
408}
409
410fn read_instance_file(path: &Path) -> Option<ConnectorInstance> {
411 #[derive(Deserialize)]
412 struct RawInstance {
413 pid: u32,
414 ws_port: u16,
415 #[serde(default)]
416 mcp_port: Option<u16>,
417 #[serde(default)]
418 bridge_port: Option<u16>,
419 #[serde(default)]
420 app_name: Option<String>,
421 #[serde(default)]
422 app_id: Option<String>,
423 #[serde(default, alias = "appInstanceId")]
424 app_instance_id: Option<String>,
425 #[serde(default)]
426 log_dir: Option<PathBuf>,
427 #[serde(default)]
428 exe: Option<PathBuf>,
429 #[serde(default)]
430 started_at: Option<u64>,
431 }
432
433 let raw: RawInstance = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
434 Some(ConnectorInstance {
435 pid: raw.pid,
436 ws_port: raw.ws_port,
437 mcp_port: raw.mcp_port,
438 bridge_port: raw.bridge_port,
439 app_name: raw.app_name,
440 app_id: raw.app_id,
441 app_instance_id: raw.app_instance_id,
442 log_dir: raw.log_dir,
443 exe: raw.exe,
444 started_at: raw.started_at,
445 pid_file: path.to_path_buf(),
446 })
447}
448
449pub async fn ping_ws(host: &str, port: u16, timeout_ms: u64) -> Result<(), String> {
451 let url = format!("ws://{host}:{port}");
452 let connect = tokio_tungstenite::connect_async(&url);
453 let (mut ws, _) = tokio::time::timeout(Duration::from_millis(timeout_ms), connect)
454 .await
455 .map_err(|_| "connect timed out".to_string())?
456 .map_err(|e| format!("connect failed: {e}"))?;
457
458 let payload = json!({ "id": "discovery-ping", "type": "ping" }).to_string();
459 ws.send(Message::Text(payload.into()))
460 .await
461 .map_err(|e| format!("ping send failed: {e}"))?;
462
463 let next = tokio::time::timeout(Duration::from_millis(timeout_ms), ws.next())
464 .await
465 .map_err(|_| "ping timed out".to_string())?;
466 let Some(Ok(Message::Text(text))) = next else {
467 return Err("ping returned no text response".to_string());
468 };
469 let value: serde_json::Value =
470 serde_json::from_str(text.as_ref()).map_err(|e| format!("invalid ping JSON: {e}"))?;
471 if value.get("result").and_then(|v| v.as_str()) == Some("pong") {
472 Ok(())
473 } else {
474 Err("ping did not return pong".to_string())
475 }
476}
477
478#[cfg(unix)]
479fn pid_is_alive(pid: u32) -> bool {
480 unsafe extern "C" {
481 fn kill(pid: i32, sig: i32) -> i32;
482 }
483 unsafe { kill(pid as i32, 0) == 0 }
484}
485
486#[cfg(not(unix))]
487fn pid_is_alive(_pid: u32) -> bool {
488 true
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494
495 #[test]
496 fn candidates_include_documented_locations() {
497 let cwd = Path::new("/tmp/example/app");
498 let candidates = pid_file_candidates(cwd);
499 assert!(candidates
500 .iter()
501 .any(|p| p.ends_with("src-tauri/target/.connector.json")));
502 assert!(candidates
503 .iter()
504 .any(|p| p.ends_with("target/debug/.connector.json")));
505 }
506
507 #[test]
508 fn instance_snapshot_dir_prefers_log_dir() {
509 let instance = ConnectorInstance {
510 pid: 42,
511 ws_port: 9555,
512 mcp_port: None,
513 bridge_port: None,
514 app_name: None,
515 app_id: None,
516 app_instance_id: None,
517 log_dir: Some(PathBuf::from("/tmp/logs")),
518 exe: None,
519 started_at: None,
520 pid_file: PathBuf::from("/tmp/.connector.json"),
521 };
522 assert_eq!(
523 instance.snapshots_dir(),
524 PathBuf::from("/tmp/logs/snapshots")
525 );
526 }
527}