discord-proxy 0.1.0

Windows-first Discord process-local proxy launcher
Documentation
pub mod bridge;
pub mod cli;
pub mod config;
pub mod discord;
pub mod proxy;

use anyhow::{Context, Result, bail};
use bridge::ProxyBridge;
use cli::{BridgeArgs, Cli, Commands, DoctorArgs, LaunchArgs};
use discord::{DiscordChannel, discover_install, inspect_candidates, is_process_running};
use proxy::UpstreamProxy;

pub async fn run(cli: Cli) -> Result<()> {
    let file_config = config::load(cli.config.as_deref())?;

    match cli.command {
        Commands::Launch(args) => launch(args, file_config).await,
        Commands::Doctor(args) => doctor(args, file_config),
        Commands::Bridge(args) => run_bridge(args).await,
    }
}

async fn launch(args: LaunchArgs, file_config: config::FileConfig) -> Result<()> {
    let proxy_text = args
        .proxy
        .or(file_config.proxy)
        .context("missing proxy; pass --proxy or create discord-proxy.toml")?;
    let upstream = UpstreamProxy::parse(&proxy_text)?;

    let channel = resolve_channel(args.channel, file_config.channel)?;
    let discord_dir = args.discord_dir.or(file_config.discord_dir);
    let install = discover_install(channel, discord_dir.as_deref())?;

    let should_bridge = !args.no_bridge && upstream.needs_bridge();
    let proxy_for_discord = if should_bridge && args.dry_run {
        planned_bridge_url(args.listen_port)
    } else if should_bridge {
        String::new()
    } else {
        upstream.command_line_url()
    };

    if args.no_bridge && upstream.needs_bridge() {
        if upstream.has_auth() {
            bail!(
                "--no-bridge cannot be used with authenticated proxies because credentials are not passed on the Discord command line"
            );
        }
        tracing::warn!("SOCKS proxies usually need the local bridge; --no-bridge may fail");
    }

    if args.dry_run {
        let plan = discord::LaunchPlan::new(install, proxy_for_discord);
        println!("{}", plan.describe());
        if should_bridge {
            match args.listen_port {
                Some(port) => println!("bridge: required for this proxy; port {port} will be used"),
                None => {
                    println!("bridge: required for this proxy; random port is assigned at runtime")
                }
            }
        }
        return Ok(());
    }

    let bridge = if should_bridge {
        Some(
            ProxyBridge::start(upstream.clone(), args.listen_port)
                .await
                .context("failed to start local proxy bridge")?,
        )
    } else {
        None
    };
    let proxy_for_discord = bridge
        .as_ref()
        .map(ProxyBridge::local_proxy_url)
        .unwrap_or(proxy_for_discord);
    if is_process_running(install.exe_name()) {
        tracing::warn!(
            "{} is already running; Discord may reuse the existing process and ignore new proxy arguments",
            install.exe_name()
        );
    }
    let plan = discord::LaunchPlan::new(install, proxy_for_discord);
    let mut child = plan.spawn().context("failed to start Discord")?;
    tracing::info!("Discord launcher process started");

    if let Some(bridge) = bridge {
        tracing::info!(
            "local proxy bridge is running at {}; keep this process open while Discord uses the proxy",
            bridge.local_proxy_url()
        );
        tokio::select! {
            status = child.wait() => {
                match status {
                    Ok(status) => tracing::info!("Update.exe exited with {status}"),
                    Err(error) => tracing::warn!("failed to wait for Update.exe: {error}"),
                }
                tokio::signal::ctrl_c().await.context("failed to wait for Ctrl+C")?;
            }
            signal = tokio::signal::ctrl_c() => {
                signal.context("failed to wait for Ctrl+C")?;
            }
        }
        bridge.shutdown().await?;
    }

    Ok(())
}

fn planned_bridge_url(port: Option<u16>) -> String {
    match port {
        Some(port) => format!("http://127.0.0.1:{port}"),
        None => "http://127.0.0.1:<random>".to_string(),
    }
}

fn doctor(args: DoctorArgs, file_config: config::FileConfig) -> Result<()> {
    let channel = resolve_channel(args.channel, file_config.channel)?;
    let discord_dir = args.discord_dir.or(file_config.discord_dir);
    let reports = inspect_candidates(channel, discord_dir.as_deref());

    if reports.is_empty() {
        println!("no candidates found");
    } else {
        println!("candidates:");
        for report in &reports {
            let status = if report.install().is_some() {
                "ok"
            } else {
                "skip"
            };
            let normalized = report
                .normalized_root()
                .map(|path| path.display().to_string())
                .unwrap_or_else(|| "<none>".to_string());
            let detail = report
                .install()
                .map(|install| {
                    format!(
                        "app dir: {}, exe: {}",
                        install.app_dir().display(),
                        install.exe_name()
                    )
                })
                .or_else(|| report.error().map(str::to_string))
                .unwrap_or_else(|| "unknown".to_string());

            println!(
                "- [{status}] {}: {} -> {} ({detail})",
                report.source(),
                report.path().display(),
                normalized
            );
        }
    }

    let install = discover_install(channel, discord_dir.as_deref())?;

    println!("channel: {}", install.channel());
    println!("source: {}", install.source());
    println!("root: {}", install.root().display());
    println!("app dir: {}", install.app_dir().display());
    if let Some(update_exe) = install.update_exe() {
        println!("update exe: {}", update_exe.display());
    } else {
        println!("update exe: <direct launch>");
    }
    println!("exe path: {}", install.exe_path().display());
    println!("discord exe: {}", install.exe_name());

    Ok(())
}

async fn run_bridge(args: BridgeArgs) -> Result<()> {
    let upstream = UpstreamProxy::parse(&args.proxy)?;
    let bridge = ProxyBridge::start(upstream, args.listen_port)
        .await
        .context("failed to start local proxy bridge")?;

    println!("{}", bridge.local_proxy_url());
    tracing::info!("local proxy bridge is running; press Ctrl+C to stop");
    tokio::signal::ctrl_c()
        .await
        .context("failed to wait for Ctrl+C")?;
    bridge.shutdown().await
}

fn resolve_channel(cli: Option<String>, config: Option<String>) -> Result<DiscordChannel> {
    let value = cli.or(config).unwrap_or_else(|| "stable".to_string());
    value.parse().map_err(|error| {
        let valid = DiscordChannel::valid_values().join(", ");
        anyhow::anyhow!("{error}; valid channels: {valid}")
    })
}

pub fn ensure_proxy_is_supported(upstream: &UpstreamProxy) -> Result<()> {
    if upstream.host().is_empty() {
        bail!("proxy host cannot be empty");
    }
    Ok(())
}