nexus_core/host/
process.rs1use std::process::Stdio;
6use std::time::Duration;
7
8use anyhow::{Context as _, Result, bail};
9use tokio::io::{AsyncBufReadExt, BufReader};
10use tokio::process::{Child, Command};
11
12pub struct ChildGuard {
14 child: Option<Child>,
15}
16
17impl ChildGuard {
18 pub async fn stop(&mut self) {
20 if let Some(child) = self.child.as_mut() {
21 let _ = child.kill().await;
22 let _ = child.wait().await;
23 }
24 self.child = None;
25 }
26}
27
28pub struct Tunnel {
32 child: Option<Child>,
33 public_url: Option<String>,
34}
35
36impl Tunnel {
37 pub async fn quick(port: u16) -> Result<Self> {
41 let mut child = Command::new("cloudflared")
42 .args([
43 "tunnel",
44 "--no-autoupdate",
45 "--url",
46 &format!("http://127.0.0.1:{port}"),
47 ])
48 .stdout(Stdio::null())
49 .stderr(Stdio::piped())
50 .stdin(Stdio::null())
51 .kill_on_drop(true)
52 .spawn()
53 .context("starting cloudflared quick tunnel")?;
54 let stderr = child
55 .stderr
56 .take()
57 .context("capturing cloudflared output")?;
58 let mut lines = BufReader::new(stderr).lines();
59 let deadline = tokio::time::sleep(Duration::from_secs(15));
60 tokio::pin!(deadline);
61 let mut public_url = None;
62 loop {
63 tokio::select! {
64 line = lines.next_line() => match line {
65 Ok(Some(line)) => {
66 if let Some(url) = parse_trycloudflare_url(&line) {
67 public_url = Some(url);
68 break;
69 }
70 }
71 _ => break,
72 },
73 () = &mut deadline => break,
74 }
75 }
76 tokio::spawn(async move { while lines.next_line().await.ok().flatten().is_some() {} });
79 Ok(Self {
80 child: Some(child),
81 public_url,
82 })
83 }
84
85 pub fn named(config: &std::path::Path, tunnel_id: &str) -> Result<Self> {
87 let mut child = Command::new("cloudflared")
88 .args(["tunnel", "--config"])
89 .arg(config)
90 .args(["run", tunnel_id])
91 .stdout(Stdio::null())
92 .stderr(Stdio::null())
93 .stdin(Stdio::null())
94 .kill_on_drop(true)
95 .spawn()
96 .context("starting cloudflared named tunnel")?;
97 if child.try_wait().ok().flatten().is_some() {
100 bail!("cloudflared named tunnel exited during startup");
101 }
102 Ok(Self {
103 child: Some(child),
104 public_url: None,
105 })
106 }
107
108 pub fn public_url(&self) -> Option<&str> {
110 self.public_url.as_deref()
111 }
112
113 pub async fn wait(&mut self) -> Option<bool> {
115 let child = self.child.as_mut()?;
116 Some(child.wait().await.is_ok_and(|status| status.success()))
117 }
118
119 pub async fn stop(&mut self) {
121 if let Some(child) = self.child.as_mut() {
122 let _ = child.kill().await;
123 let _ = child.wait().await;
124 }
125 self.child = None;
126 }
127}
128
129pub fn sleep_guard() -> Result<Option<ChildGuard>> {
133 #[cfg(target_os = "macos")]
134 {
135 let child = Command::new("caffeinate")
136 .args(["-dimsu"])
137 .stdin(Stdio::null())
138 .stdout(Stdio::null())
139 .stderr(Stdio::null())
140 .kill_on_drop(true)
141 .spawn()
142 .context("starting macOS caffeinate")?;
143 return Ok(Some(ChildGuard { child: Some(child) }));
144 }
145 #[cfg(target_os = "linux")]
146 {
147 let child = Command::new("systemd-inhibit")
148 .args([
149 "--what=sleep",
150 "--who=nexus",
151 "--mode=block",
152 "sleep",
153 "infinity",
154 ])
155 .stdin(Stdio::null())
156 .stdout(Stdio::null())
157 .stderr(Stdio::null())
158 .kill_on_drop(true)
159 .spawn()
160 .context("starting systemd-inhibit")?;
161 return Ok(Some(ChildGuard { child: Some(child) }));
162 }
163 #[allow(unreachable_code)]
164 Ok(None)
165}
166
167pub fn cloudflared_available() -> bool {
169 std::process::Command::new("cloudflared")
170 .arg("--version")
171 .stdout(Stdio::null())
172 .stderr(Stdio::null())
173 .status()
174 .is_ok_and(|status| status.success())
175}
176
177fn parse_trycloudflare_url(line: &str) -> Option<String> {
178 let start = line.find("https://")?;
179 let candidate = &line[start..];
180 let end = candidate
181 .find(|character: char| {
182 character.is_whitespace() || matches!(character, '"' | '\'' | ')' | ']')
183 })
184 .unwrap_or(candidate.len());
185 let url = &candidate[..end];
186 url.contains(".trycloudflare.com")
187 .then(|| url.trim_end_matches('/').to_string())
188}
189
190pub async fn health_check(base: &str) -> bool {
193 let url = format!("{}/v1/snapshot", base.trim_end_matches('/'));
194 reqwest::Client::new()
195 .get(url)
196 .timeout(Duration::from_secs(3))
197 .send()
198 .await
199 .is_ok_and(|response| response.status().is_success() || response.status().as_u16() == 401)
200}
201
202pub fn on_battery() -> Option<bool> {
204 #[cfg(target_os = "macos")]
205 {
206 let output = std::process::Command::new("pmset")
207 .args(["-g", "batt"])
208 .output()
209 .ok()?;
210 let text = String::from_utf8_lossy(&output.stdout);
211 return Some(text.contains("Battery Power") && !text.contains("AC Power"));
212 }
213 #[cfg(not(target_os = "macos"))]
214 {
215 None
216 }
217}
218
219pub fn require_cloudflared() -> Result<()> {
221 if cloudflared_available() {
222 Ok(())
223 } else {
224 bail!("cloudflared is not in PATH — install it or run without --tunnel")
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::parse_trycloudflare_url;
231
232 #[test]
233 fn parses_quick_tunnel_url_from_cloudflared_log() {
234 assert_eq!(
235 parse_trycloudflare_url("INF | https://quiet-river.trycloudflare.com").as_deref(),
236 Some("https://quiet-river.trycloudflare.com")
237 );
238 assert!(parse_trycloudflare_url("no public URL yet").is_none());
239 }
240}