1mod endpoint;
2mod env;
3pub use env::RuntimeEnv;
4#[cfg(unix)]
5mod unix_process;
6#[cfg(windows)]
7mod windows_process;
8
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::sync::OnceLock;
12
13pub use endpoint::{
14 meowclient_endpoint_label_for_workdir, meowclient_named_pipe_name_for_workdir,
15 workdir_identity_hash,
16};
17#[cfg(unix)]
18pub use unix_process::{
19 clear_stale_unix_socket, is_pid_alive, process_executable_path, unix_peer_pid,
20};
21#[cfg(windows)]
22pub use windows_process::{is_pid_alive, process_executable_path};
23
24pub fn paths_match(actual: &Path, expected: &Path) -> bool {
26 match (actual.canonicalize(), expected.canonicalize()) {
27 (Ok(actual), Ok(expected)) => actual == expected,
28 _ => actual == expected,
29 }
30}
31
32pub fn pid_matches_executable(pid: u32, expected_binary: &Path) -> bool {
34 if !is_pid_alive(pid) {
35 return false;
36 }
37 match process_executable_path(pid) {
38 Some(path) => paths_match(&path, expected_binary),
39 None => false,
40 }
41}
42
43pub fn owned_daemon_pid_from_file(
48 pid_file: &Path,
49 expected_binary: &Path,
50) -> Result<Option<u32>, String> {
51 let raw = match fs::read_to_string(pid_file) {
52 Ok(value) => value,
53 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
54 Err(err) => {
55 return Err(format!(
56 "failed to read daemon pid file {}: {}",
57 pid_file.display(),
58 err
59 ))
60 }
61 };
62 let pid = raw
63 .trim()
64 .parse::<u32>()
65 .map_err(|err| format!("invalid daemon pid in {}: {}", pid_file.display(), err))?;
66
67 if !is_pid_alive(pid) {
68 match fs::remove_file(pid_file) {
69 Ok(()) => {}
70 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
71 Err(err) => {
72 return Err(format!(
73 "failed to remove stale daemon pid file {}: {}",
74 pid_file.display(),
75 err
76 ))
77 }
78 }
79 return Ok(None);
80 }
81 if !pid_matches_executable(pid, expected_binary) {
82 return Ok(None);
83 }
84 Ok(Some(pid))
85}
86
87pub fn owned_meowclient_pid(expected_binary: &Path) -> Result<Option<u32>, String> {
89 owned_daemon_pid_from_file(&runtime_paths().meowclient_pid_file(), expected_binary)
90}
91
92static RUNTIME_WORKDIR: OnceLock<(PathBuf, RuntimeEnv)> = OnceLock::new();
93
94#[derive(Debug, Clone)]
95pub struct RuntimePaths {
96 workdir: PathBuf,
97}
98
99impl RuntimePaths {
100 pub fn new(workdir: PathBuf) -> Self {
101 Self { workdir }
102 }
103
104 pub fn for_workdir(workdir: impl AsRef<Path>) -> Self {
105 Self::new(workdir.as_ref().to_path_buf())
106 }
107
108 pub fn workdir(&self) -> &Path {
109 &self.workdir
110 }
111
112 pub fn system_root(&self) -> PathBuf {
113 self.workdir.join("system")
114 }
115
116 pub fn services_root(&self) -> PathBuf {
117 self.service_packages_root()
118 }
119
120 pub fn packages_root(&self) -> PathBuf {
121 self.workdir.join("packages")
122 }
123
124 pub fn service_packages_root(&self) -> PathBuf {
125 self.packages_root().join("services")
126 }
127
128 pub fn service_package_dir(&self, service_id: &str) -> PathBuf {
129 self.service_packages_root().join(service_id)
130 }
131
132 pub fn config_root(&self) -> PathBuf {
133 self.workdir.join("config")
134 }
135
136 pub fn run_root(&self) -> PathBuf {
137 self.workdir.join("run")
138 }
139
140 pub fn log_root(&self) -> PathBuf {
141 self.workdir.join("log")
142 }
143
144 pub fn data_root(&self) -> PathBuf {
145 self.workdir.join("data")
146 }
147
148 pub fn state_root(&self) -> PathBuf {
149 self.workdir.join("state")
150 }
151
152 pub fn downloads_root(&self) -> PathBuf {
153 self.workdir.join("downloads")
154 }
155
156 pub fn tools_root(&self) -> PathBuf {
157 self.workdir.join("tools")
158 }
159
160 pub fn downloads_tools_root(&self) -> PathBuf {
161 self.downloads_root().join("tools")
162 }
163
164 pub fn artifacts_root(&self) -> PathBuf {
165 self.workdir.join("artifacts")
166 }
167
168 pub fn tmp_root(&self) -> PathBuf {
170 self.workdir.join("tmp")
171 }
172
173 pub fn component_config_path(&self, component_name: &str) -> PathBuf {
174 self.config_root().join(format!("{component_name}.yaml"))
175 }
176
177 pub fn component_run_dir(&self, component_name: &str) -> PathBuf {
178 self.run_root().join(component_name)
179 }
180
181 pub fn component_data_dir(&self, component_name: &str) -> PathBuf {
182 self.data_root().join(component_name)
183 }
184
185 pub fn component_state_dir(&self, component_name: &str) -> PathBuf {
186 self.state_root().join(component_name)
187 }
188
189 pub fn component_system_dir(&self, component_name: &str) -> PathBuf {
190 self.system_root().join(component_name)
191 }
192
193 pub fn component_log_path(&self, component_name: &str) -> PathBuf {
194 self.log_root().join(format!("{component_name}.log"))
195 }
196
197 pub fn service_data_dir(&self, service_id: &str) -> PathBuf {
198 self.data_root().join("services").join(service_id)
199 }
200
201 pub fn service_run_dir(&self, service_id: &str) -> PathBuf {
202 self.run_root().join("services").join(service_id)
203 }
204
205 pub fn service_log_path(&self, service_id: &str) -> PathBuf {
206 self.log_root()
207 .join(format!("{}.log", normalized_service_log_name(service_id)))
208 }
209
210 pub fn identity_file_path(&self) -> PathBuf {
211 self.system_root().join("device_identity.json")
212 }
213
214 pub fn meowclient_socket_path(&self) -> PathBuf {
215 self.component_run_dir("meowclient").join("meowd.sock")
216 }
217
218 pub fn meowclient_pid_file(&self) -> PathBuf {
219 self.component_run_dir("meowclient").join("meowd.pid")
220 }
221
222 pub fn meowclient_ipc_token_file(&self) -> PathBuf {
223 self.component_run_dir("meowclient").join("meowd.ipc.token")
224 }
225
226 pub fn meowclient_named_pipe_name(&self) -> String {
227 meowclient_named_pipe_name_for_workdir(&self.workdir)
228 }
229
230 pub fn meowclient_endpoint_label(&self) -> String {
231 meowclient_endpoint_label_for_workdir(&self.workdir)
232 }
233}
234
235fn normalized_service_log_name(service_id: &str) -> String {
236 match service_id {
237 "meowcore" | "meow-core" => "meow-core".to_string(),
238 value if value.starts_with("node-") => value.to_string(),
239 value => format!("node-{value}"),
240 }
241}
242
243pub fn set_runtime_workdir(workdir: PathBuf) -> anyhow::Result<()> {
246 set_runtime_context(workdir, RuntimeEnv::Prod)
247}
248
249pub fn set_runtime_context(workdir: PathBuf, env: RuntimeEnv) -> anyhow::Result<()> {
252 anyhow::ensure!(workdir.is_absolute(), "runtime workdir must be absolute");
253 match RUNTIME_WORKDIR.get() {
254 Some((existing, existing_env)) if existing == &workdir && *existing_env == env => Ok(()),
255 Some((existing, _)) => Err(anyhow::anyhow!(
256 "runtime workdir/environment already set to {}, cannot change to {}",
257 existing.display(),
258 workdir.display()
259 )),
260 None => RUNTIME_WORKDIR
261 .set((workdir, env))
262 .map_err(|_| anyhow::anyhow!("failed to set runtime workdir")),
263 }
264}
265
266pub fn runtime_workdir() -> PathBuf {
267 RUNTIME_WORKDIR
268 .get()
269 .expect("runtime workdir is not initialized at process startup")
270 .0
271 .clone()
272}
273
274pub fn runtime_env() -> RuntimeEnv {
277 RUNTIME_WORKDIR
278 .get()
279 .map(|(_, env)| *env)
280 .unwrap_or_default()
281}
282
283pub fn runtime_paths() -> RuntimePaths {
284 RuntimePaths::new(runtime_workdir())
285}
286
287pub fn resolve_runtime_workdir(home: Option<&Path>, env: RuntimeEnv) -> anyhow::Result<PathBuf> {
290 let home = match home {
291 Some(home) => home.to_path_buf(),
292 None => default_runtime_root()?,
293 };
294 anyhow::ensure!(home.is_absolute(), "runtime home must be absolute");
295 let workdir = if env.is_prod() {
296 home.clone()
297 } else {
298 home.join(env.as_str())
299 };
300 if !env.is_prod() {
301 match std::fs::symlink_metadata(&workdir) {
302 Ok(metadata) => anyhow::ensure!(
303 !metadata.file_type().is_symlink(),
304 "Dev/E2E workdir must not redirect to another environment"
305 ),
306 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
307 Err(error) => return Err(error.into()),
308 }
309 }
310 Ok(workdir)
311}
312
313pub fn resolve_workdir(dev: bool) -> PathBuf {
315 resolve_runtime_workdir(
316 None,
317 if dev {
318 RuntimeEnv::Dev
319 } else {
320 RuntimeEnv::Prod
321 },
322 )
323 .expect("failed to resolve default runtime workdir")
324}
325
326pub fn default_runtime_root() -> anyhow::Result<PathBuf> {
327 #[cfg(windows)]
328 {
329 let local_app_data = std::env::var_os("LOCALAPPDATA")
330 .filter(|value| !value.is_empty())
331 .ok_or_else(|| anyhow::anyhow!("LOCALAPPDATA is not set"))?;
332 return Ok(PathBuf::from(local_app_data).join(".meow"));
333 }
334
335 #[cfg(not(windows))]
336 {
337 let home = home_dir().ok_or_else(|| anyhow::anyhow!("HOME is not set"))?;
338 Ok(home.join(".meow"))
339 }
340}
341
342#[cfg(not(windows))]
343fn home_dir() -> Option<PathBuf> {
344 std::env::var_os("HOME")
345 .filter(|value| !value.is_empty())
346 .map(PathBuf::from)
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn service_log_paths_use_standard_log_names() {
355 let paths = RuntimePaths::for_workdir("/tmp/meow-runtime-test");
356
357 assert_eq!(
358 paths.service_log_path("camera"),
359 PathBuf::from("/tmp/meow-runtime-test/log/node-camera.log")
360 );
361 assert_eq!(
362 paths.service_log_path("node-camera"),
363 PathBuf::from("/tmp/meow-runtime-test/log/node-camera.log")
364 );
365 assert_eq!(
366 paths.service_log_path("meowcore"),
367 PathBuf::from("/tmp/meow-runtime-test/log/meow-core.log")
368 );
369 }
370
371 #[test]
372 fn tmp_root_is_workdir_scoped() {
373 let paths = RuntimePaths::for_workdir("/tmp/meow-runtime-test");
374 assert_eq!(
375 paths.tmp_root(),
376 PathBuf::from("/tmp/meow-runtime-test/tmp")
377 );
378 }
379
380 #[cfg(not(windows))]
381 #[test]
382 fn default_runtime_root_uses_dot_meow_under_home() {
383 let root = default_runtime_root().unwrap();
384 assert_eq!(
385 root.file_name().and_then(|value| value.to_str()),
386 Some(".meow")
387 );
388 }
389}