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(())
}