shellphone 0.3.1

Pipe CLI commands to a secure mobile web terminal
mod bore;
mod cloudflared;
mod custom;
mod ngrok;
mod tailscale;

use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;

pub enum TunnelResult {
    Url(String),
    Unavailable(String),
}

#[derive(Clone, Debug)]
pub enum Provider {
    Cloudflared,
    Ngrok,
    Bore { server: String },
    Tailscale,
    Custom { cmd: String },
    Auto,
}

impl Provider {
    pub async fn start(&self, local_port: u16) -> TunnelResult {
        match self {
            Provider::Cloudflared => cloudflared::start(local_port).await,
            Provider::Ngrok => ngrok::start(local_port).await,
            Provider::Bore { server } => bore::start(local_port, server).await,
            Provider::Tailscale => tailscale::start(local_port).await,
            Provider::Custom { cmd } => custom::start(local_port, cmd).await,
            Provider::Auto => auto_detect(local_port).await,
        }
    }
}

async fn auto_detect(local_port: u16) -> TunnelResult {
    let candidates: &[(&str, fn() -> Provider)] = &[
        ("cloudflared", || Provider::Cloudflared),
        ("ngrok", || Provider::Ngrok),
        ("bore", || Provider::Bore { server: "bore.pub".into() }),
        ("tailscale", || Provider::Tailscale),
    ];
    for &(name, make_provider) in candidates {
        if which(name).await {
            tracing::info!("Auto-detected tunnel provider: {name}");
            return match make_provider() {
                Provider::Cloudflared => cloudflared::start(local_port).await,
                Provider::Ngrok => ngrok::start(local_port).await,
                Provider::Bore { server } => bore::start(local_port, &server).await,
                Provider::Tailscale => tailscale::start(local_port).await,
                _ => unreachable!(),
            };
        }
    }
    TunnelResult::Unavailable("No tunnel provider found. Install cloudflared, ngrok, bore, or tailscale, or use --tunnel-cmd.".into())
}

async fn which(name: &str) -> bool {
    Command::new("which")
        .arg(name)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .await
        .is_ok_and(|s| s.success())
}

async fn parse_url_from_output(
    mut child: tokio::process::Child,
    url_pattern: fn(&str) -> bool,
    timeout_secs: u64,
    provider_name: &str,
) -> TunnelResult {
    let stderr = child.stderr.take();
    let stdout = child.stdout.take();

    let (url_tx, url_rx) = tokio::sync::oneshot::channel::<String>();
    let url_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(url_tx)));

    fn scan_stream(
        reader: impl tokio::io::AsyncRead + Unpin + Send + 'static,
        url_pattern: fn(&str) -> bool,
        url_tx: std::sync::Arc<std::sync::Mutex<Option<tokio::sync::oneshot::Sender<String>>>>,
        label: String,
    ) {
        tokio::spawn(async move {
            let mut lines = BufReader::new(reader);
            let mut buf = String::new();
            loop {
                buf.clear();
                match lines.read_line(&mut buf).await {
                    Ok(0) | Err(_) => break,
                    Ok(_) => {
                        let line = buf.trim();
                        tracing::debug!("[{label}] {line}");
                        if let Some(url) = extract_url(line, url_pattern) {
                            if let Some(tx) = url_tx.lock().unwrap().take() {
                                let _ = tx.send(url);
                            }
                        }
                    }
                }
            }
        });
    }

    let label = provider_name.to_string();
    if let Some(stderr) = stderr {
        scan_stream(stderr, url_pattern, url_tx.clone(), format!("{label}/err"));
    }
    if let Some(stdout) = stdout {
        scan_stream(stdout, url_pattern, url_tx.clone(), format!("{label}/out"));
    }

    tokio::spawn(async move {
        let _ = child.wait().await;
    });

    match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), url_rx).await {
        Ok(Ok(url)) => TunnelResult::Url(url),
        _ => TunnelResult::Unavailable(format!(
            "Timed out waiting for {provider_name} tunnel URL"
        )),
    }
}

fn extract_url(line: &str, pattern: fn(&str) -> bool) -> Option<String> {
    let start = line.find("https://")?;
    let url_part = &line[start..];
    let url = url_part
        .split(|c: char| c.is_whitespace() || c == '|' || c == '"' || c == '\'')
        .next()?
        .trim_end_matches(|c: char| c == '/' || c == '.' || c == ',');

    if pattern(url) {
        Some(url.to_string())
    } else {
        None
    }
}