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 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/// Connector instance metadata written by the plugin into `.connector.json`.
16#[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    /// Snapshot directory used by the plugin.
33    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/// Discovery inputs shared by CLI and standalone MCP server.
42#[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/// How the active connection was resolved.
64#[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/// Resolved WebSocket endpoint plus optional instance metadata.
74#[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/// Status for one discovered PID file.
83#[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
92/// Resolve an endpoint using explicit inputs, environment, PID files, then scan.
93pub 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
185/// Return statuses for every PID file candidate.
186pub 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
217/// Read all matching `.connector.json` files near `cwd`.
218pub 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
250/// Build the candidate list documented by the CLI/playbook.
251pub 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
302/// Ping a connector WebSocket endpoint.
303pub 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}