Skip to main content

connector_client/
discovery.rs

1//! Discovery helpers for locating running tauri-connector instances.
2
3use 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/// Connector instance metadata written by the plugin into `.connector.json`.
17#[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    /// Snapshot directory used by the plugin.
35    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/// Discovery inputs shared by CLI and standalone MCP server.
44#[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/// How the active connection was resolved.
68#[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/// Resolved WebSocket endpoint plus optional instance metadata.
78#[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/// Status for one discovered PID file.
88#[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
97/// Resolve every candidate against its live identity. Explicit constraints never fall back.
98pub 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
327/// Return statuses for every PID file candidate.
328pub 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
362/// Read all matching `.connector.json` files near `cwd`.
363pub 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
394/// Build the candidate list documented by the CLI/playbook.
395pub 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
449/// Ping a connector WebSocket endpoint.
450pub 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}