use std::process::Stdio;
use std::time::Duration;
use anyhow::{Context as _, Result, bail};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
pub struct ChildGuard {
child: Option<Child>,
}
impl ChildGuard {
pub async fn stop(&mut self) {
if let Some(child) = self.child.as_mut() {
let _ = child.kill().await;
let _ = child.wait().await;
}
self.child = None;
}
}
pub struct Tunnel {
child: Option<Child>,
public_url: Option<String>,
}
impl Tunnel {
pub async fn quick(port: u16) -> Result<Self> {
let mut child = Command::new("cloudflared")
.args([
"tunnel",
"--no-autoupdate",
"--url",
&format!("http://127.0.0.1:{port}"),
])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.stdin(Stdio::null())
.kill_on_drop(true)
.spawn()
.context("starting cloudflared quick tunnel")?;
let stderr = child
.stderr
.take()
.context("capturing cloudflared output")?;
let mut lines = BufReader::new(stderr).lines();
let deadline = tokio::time::sleep(Duration::from_secs(15));
tokio::pin!(deadline);
let mut public_url = None;
loop {
tokio::select! {
line = lines.next_line() => match line {
Ok(Some(line)) => {
if let Some(url) = parse_trycloudflare_url(&line) {
public_url = Some(url);
break;
}
}
_ => break,
},
() = &mut deadline => break,
}
}
tokio::spawn(async move { while lines.next_line().await.ok().flatten().is_some() {} });
Ok(Self {
child: Some(child),
public_url,
})
}
pub fn named(config: &std::path::Path, tunnel_id: &str) -> Result<Self> {
let mut child = Command::new("cloudflared")
.args(["tunnel", "--config"])
.arg(config)
.args(["run", tunnel_id])
.stdout(Stdio::null())
.stderr(Stdio::null())
.stdin(Stdio::null())
.kill_on_drop(true)
.spawn()
.context("starting cloudflared named tunnel")?;
if child.try_wait().ok().flatten().is_some() {
bail!("cloudflared named tunnel exited during startup");
}
Ok(Self {
child: Some(child),
public_url: None,
})
}
pub fn public_url(&self) -> Option<&str> {
self.public_url.as_deref()
}
pub async fn wait(&mut self) -> Option<bool> {
let child = self.child.as_mut()?;
Some(child.wait().await.is_ok_and(|status| status.success()))
}
pub async fn stop(&mut self) {
if let Some(child) = self.child.as_mut() {
let _ = child.kill().await;
let _ = child.wait().await;
}
self.child = None;
}
}
pub fn sleep_guard() -> Result<Option<ChildGuard>> {
#[cfg(target_os = "macos")]
{
let child = Command::new("caffeinate")
.args(["-dimsu"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
.context("starting macOS caffeinate")?;
return Ok(Some(ChildGuard { child: Some(child) }));
}
#[cfg(target_os = "linux")]
{
let child = Command::new("systemd-inhibit")
.args([
"--what=sleep",
"--who=nexus",
"--mode=block",
"sleep",
"infinity",
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
.context("starting systemd-inhibit")?;
return Ok(Some(ChildGuard { child: Some(child) }));
}
#[allow(unreachable_code)]
Ok(None)
}
pub fn cloudflared_available() -> bool {
std::process::Command::new("cloudflared")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
fn parse_trycloudflare_url(line: &str) -> Option<String> {
let start = line.find("https://")?;
let candidate = &line[start..];
let end = candidate
.find(|character: char| {
character.is_whitespace() || matches!(character, '"' | '\'' | ')' | ']')
})
.unwrap_or(candidate.len());
let url = &candidate[..end];
url.contains(".trycloudflare.com")
.then(|| url.trim_end_matches('/').to_string())
}
pub async fn health_check(base: &str) -> bool {
let url = format!("{}/v1/snapshot", base.trim_end_matches('/'));
reqwest::Client::new()
.get(url)
.timeout(Duration::from_secs(3))
.send()
.await
.is_ok_and(|response| response.status().is_success() || response.status().as_u16() == 401)
}
pub fn on_battery() -> Option<bool> {
#[cfg(target_os = "macos")]
{
let output = std::process::Command::new("pmset")
.args(["-g", "batt"])
.output()
.ok()?;
let text = String::from_utf8_lossy(&output.stdout);
return Some(text.contains("Battery Power") && !text.contains("AC Power"));
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
pub fn require_cloudflared() -> Result<()> {
if cloudflared_available() {
Ok(())
} else {
bail!("cloudflared is not in PATH — install it or run without --tunnel")
}
}
#[cfg(test)]
mod tests {
use super::parse_trycloudflare_url;
#[test]
fn parses_quick_tunnel_url_from_cloudflared_log() {
assert_eq!(
parse_trycloudflare_url("INF | https://quiet-river.trycloudflare.com").as_deref(),
Some("https://quiet-river.trycloudflare.com")
);
assert!(parse_trycloudflare_url("no public URL yet").is_none());
}
}