Skip to main content

tauri_agent_plugin/
endpoint.rs

1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4
5/// Discovery record for the human-facing VNC/noVNC visual surface. The plugin
6/// only advertises where the stream lives; the VNC server itself (for example
7/// `x11vnc`/`websockify` against the app's virtual display) is run by the
8/// surrounding harness.
9#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
10#[serde(rename_all = "camelCase")]
11pub struct VncEndpoint {
12    pub host: String,
13    pub port: u16,
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub novnc_url: Option<String>,
16}
17
18#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
19#[serde(tag = "transport", rename_all = "camelCase")]
20pub enum AgentEndpointDescriptor {
21    #[serde(rename = "unix")]
22    #[serde(rename_all = "camelCase")]
23    Unix {
24        app_id: String,
25        pid: u32,
26        path: PathBuf,
27        #[serde(default, skip_serializing_if = "Option::is_none")]
28        token: Option<String>,
29        #[serde(default, skip_serializing_if = "Option::is_none")]
30        vnc: Option<VncEndpoint>,
31    },
32    #[serde(rename = "tcp")]
33    #[serde(rename_all = "camelCase")]
34    Tcp {
35        app_id: String,
36        pid: u32,
37        host: String,
38        port: u16,
39        #[serde(default, skip_serializing_if = "Option::is_none")]
40        token: Option<String>,
41        #[serde(default, skip_serializing_if = "Option::is_none")]
42        vnc: Option<VncEndpoint>,
43    },
44}
45
46impl AgentEndpointDescriptor {
47    pub fn unix(app_id: impl Into<String>, pid: u32, path: PathBuf) -> Self {
48        Self::Unix {
49            app_id: app_id.into(),
50            pid,
51            path,
52            token: None,
53            vnc: None,
54        }
55    }
56
57    pub fn tcp(app_id: impl Into<String>, pid: u32, host: impl Into<String>, port: u16) -> Self {
58        Self::Tcp {
59            app_id: app_id.into(),
60            pid,
61            host: host.into(),
62            port,
63            token: None,
64            vnc: None,
65        }
66    }
67
68    pub fn app_id(&self) -> &str {
69        match self {
70            Self::Unix { app_id, .. } | Self::Tcp { app_id, .. } => app_id,
71        }
72    }
73
74    /// The per-session auth token a client must present, if this server requires one.
75    pub fn token(&self) -> Option<&str> {
76        match self {
77            Self::Unix { token, .. } | Self::Tcp { token, .. } => token.as_deref(),
78        }
79    }
80
81    /// Attach (or clear) the required auth token, consuming self.
82    pub fn with_token(mut self, value: Option<String>) -> Self {
83        match &mut self {
84            Self::Unix { token, .. } | Self::Tcp { token, .. } => *token = value,
85        }
86        self
87    }
88
89    /// The advertised VNC surface, if this app publishes one.
90    pub fn vnc(&self) -> Option<&VncEndpoint> {
91        match self {
92            Self::Unix { vnc, .. } | Self::Tcp { vnc, .. } => vnc.as_ref(),
93        }
94    }
95
96    /// Attach (or clear) the advertised VNC surface, consuming self.
97    pub fn with_vnc(mut self, endpoint: Option<VncEndpoint>) -> Self {
98        match &mut self {
99            Self::Unix { vnc, .. } | Self::Tcp { vnc, .. } => *vnc = endpoint,
100        }
101        self
102    }
103}
104
105#[derive(Debug, thiserror::Error)]
106pub enum EndpointRegistryError {
107    #[error("endpoint registry not found for app: {0}")]
108    NotFound(String),
109    #[error("endpoint registry I/O error at {path:?}: {source}")]
110    Io {
111        path: PathBuf,
112        #[source]
113        source: std::io::Error,
114    },
115    #[error("invalid endpoint registry at {path:?}: {source}")]
116    Json {
117        path: PathBuf,
118        #[source]
119        source: serde_json::Error,
120    },
121}
122
123pub fn endpoint_runtime_dir(app_id: &str, runtime_base: Option<PathBuf>) -> PathBuf {
124    runtime_base
125        .unwrap_or_else(default_runtime_base)
126        .join("tauri-agent")
127        .join(safe_app_id(app_id))
128}
129
130pub fn endpoint_registry_path(app_id: &str, runtime_base: Option<PathBuf>) -> PathBuf {
131    endpoint_runtime_dir(app_id, runtime_base).join("endpoint.json")
132}
133
134pub fn write_endpoint_registry(
135    descriptor: &AgentEndpointDescriptor,
136    runtime_base: Option<PathBuf>,
137) -> Result<(), EndpointRegistryError> {
138    let runtime_dir = endpoint_runtime_dir(descriptor.app_id(), runtime_base.clone());
139    std::fs::create_dir_all(&runtime_dir).map_err(|source| EndpointRegistryError::Io {
140        path: runtime_dir.clone(),
141        source,
142    })?;
143
144    let path = endpoint_registry_path(descriptor.app_id(), runtime_base);
145    let contents =
146        serde_json::to_string_pretty(descriptor).map_err(|source| EndpointRegistryError::Json {
147            path: path.clone(),
148            source,
149        })?;
150
151    // Write to a pid-scoped temp file, tighten permissions, then atomically
152    // rename into place so a concurrent reader never observes partial JSON.
153    let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
154    let write_result = std::fs::write(&tmp, format!("{contents}\n"))
155        .map_err(|source| EndpointRegistryError::Io {
156            path: tmp.clone(),
157            source,
158        })
159        .and_then(|()| restrict_registry_permissions(&tmp))
160        .and_then(|()| {
161            std::fs::rename(&tmp, &path).map_err(|source| EndpointRegistryError::Io {
162                path: path.clone(),
163                source,
164            })
165        });
166    if write_result.is_err() {
167        let _ = std::fs::remove_file(&tmp);
168    }
169    write_result
170}
171
172/// Restrict the registry file to owner-only read/write so the embedded auth
173/// token is not world-readable. No-op on non-Unix platforms, where the user
174/// temp/runtime directory is already per-user.
175#[cfg(unix)]
176fn restrict_registry_permissions(path: &std::path::Path) -> Result<(), EndpointRegistryError> {
177    use std::os::unix::fs::PermissionsExt;
178    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
179        EndpointRegistryError::Io {
180            path: path.to_path_buf(),
181            source,
182        }
183    })
184}
185
186#[cfg(not(unix))]
187fn restrict_registry_permissions(_path: &std::path::Path) -> Result<(), EndpointRegistryError> {
188    Ok(())
189}
190
191pub fn read_endpoint_registry(
192    app_id: &str,
193    runtime_base: Option<PathBuf>,
194) -> Result<AgentEndpointDescriptor, EndpointRegistryError> {
195    let path = endpoint_registry_path(app_id, runtime_base);
196    let contents = std::fs::read_to_string(&path).map_err(|source| {
197        if source.kind() == std::io::ErrorKind::NotFound {
198            EndpointRegistryError::NotFound(app_id.to_string())
199        } else {
200            EndpointRegistryError::Io {
201                path: path.clone(),
202                source,
203            }
204        }
205    })?;
206
207    serde_json::from_str(&contents).map_err(|source| EndpointRegistryError::Json { path, source })
208}
209
210pub fn remove_endpoint_registry(
211    app_id: &str,
212    runtime_base: Option<PathBuf>,
213) -> Result<(), EndpointRegistryError> {
214    let path = endpoint_registry_path(app_id, runtime_base);
215    match std::fs::remove_file(&path) {
216        Ok(()) => Ok(()),
217        Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
218        Err(source) => Err(EndpointRegistryError::Io { path, source }),
219    }
220}
221
222fn default_runtime_base() -> PathBuf {
223    std::env::var_os("XDG_RUNTIME_DIR")
224        .or_else(|| std::env::var_os("TMPDIR"))
225        .or_else(|| std::env::var_os("TEMP"))
226        .or_else(|| std::env::var_os("TMP"))
227        .map(PathBuf::from)
228        .unwrap_or_else(std::env::temp_dir)
229}
230
231fn safe_app_id(app_id: &str) -> String {
232    let sanitized: String = app_id
233        .chars()
234        .map(|ch| {
235            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
236                ch
237            } else {
238                '_'
239            }
240        })
241        .collect();
242
243    // An empty or dot-only segment ("", ".", "..", ...) would escape the runtime
244    // directory when joined as a path component; neutralize it.
245    if sanitized.is_empty() {
246        return "_".to_string();
247    }
248    if sanitized.chars().all(|ch| ch == '.') {
249        return sanitized.replace('.', "_");
250    }
251    sanitized
252}