Skip to main content

discord_proxy/
lib.rs

1pub mod bridge;
2pub mod cli;
3pub mod config;
4pub mod discord;
5pub mod proxy;
6
7use anyhow::{Context, Result, bail};
8use bridge::ProxyBridge;
9use cli::{BridgeArgs, Cli, Commands, DoctorArgs, LaunchArgs};
10use discord::{DiscordChannel, discover_install, inspect_candidates, is_process_running};
11use proxy::UpstreamProxy;
12
13pub async fn run(cli: Cli) -> Result<()> {
14    let file_config = config::load(cli.config.as_deref())?;
15
16    match cli.command {
17        Commands::Launch(args) => launch(args, file_config).await,
18        Commands::Doctor(args) => doctor(args, file_config),
19        Commands::Bridge(args) => run_bridge(args).await,
20    }
21}
22
23async fn launch(args: LaunchArgs, file_config: config::FileConfig) -> Result<()> {
24    let proxy_text = args
25        .proxy
26        .or(file_config.proxy)
27        .context("missing proxy; pass --proxy or create discord-proxy.toml")?;
28    let upstream = UpstreamProxy::parse(&proxy_text)?;
29
30    let channel = resolve_channel(args.channel, file_config.channel)?;
31    let discord_dir = args.discord_dir.or(file_config.discord_dir);
32    let install = discover_install(channel, discord_dir.as_deref())?;
33
34    let should_bridge = !args.no_bridge && upstream.needs_bridge();
35    let proxy_for_discord = if should_bridge && args.dry_run {
36        planned_bridge_url(args.listen_port)
37    } else if should_bridge {
38        String::new()
39    } else {
40        upstream.command_line_url()
41    };
42
43    if args.no_bridge && upstream.needs_bridge() {
44        if upstream.has_auth() {
45            bail!(
46                "--no-bridge cannot be used with authenticated proxies because credentials are not passed on the Discord command line"
47            );
48        }
49        tracing::warn!("SOCKS proxies usually need the local bridge; --no-bridge may fail");
50    }
51
52    if args.dry_run {
53        let plan = discord::LaunchPlan::new(install, proxy_for_discord);
54        println!("{}", plan.describe());
55        if should_bridge {
56            match args.listen_port {
57                Some(port) => println!("bridge: required for this proxy; port {port} will be used"),
58                None => {
59                    println!("bridge: required for this proxy; random port is assigned at runtime")
60                }
61            }
62        }
63        return Ok(());
64    }
65
66    let bridge = if should_bridge {
67        Some(
68            ProxyBridge::start(upstream.clone(), args.listen_port)
69                .await
70                .context("failed to start local proxy bridge")?,
71        )
72    } else {
73        None
74    };
75    let proxy_for_discord = bridge
76        .as_ref()
77        .map(ProxyBridge::local_proxy_url)
78        .unwrap_or(proxy_for_discord);
79    if is_process_running(install.exe_name()) {
80        tracing::warn!(
81            "{} is already running; Discord may reuse the existing process and ignore new proxy arguments",
82            install.exe_name()
83        );
84    }
85    let plan = discord::LaunchPlan::new(install, proxy_for_discord);
86    let mut child = plan.spawn().context("failed to start Discord")?;
87    tracing::info!("Discord launcher process started");
88
89    if let Some(bridge) = bridge {
90        tracing::info!(
91            "local proxy bridge is running at {}; keep this process open while Discord uses the proxy",
92            bridge.local_proxy_url()
93        );
94        tokio::select! {
95            status = child.wait() => {
96                match status {
97                    Ok(status) => tracing::info!("Update.exe exited with {status}"),
98                    Err(error) => tracing::warn!("failed to wait for Update.exe: {error}"),
99                }
100                tokio::signal::ctrl_c().await.context("failed to wait for Ctrl+C")?;
101            }
102            signal = tokio::signal::ctrl_c() => {
103                signal.context("failed to wait for Ctrl+C")?;
104            }
105        }
106        bridge.shutdown().await?;
107    }
108
109    Ok(())
110}
111
112fn planned_bridge_url(port: Option<u16>) -> String {
113    match port {
114        Some(port) => format!("http://127.0.0.1:{port}"),
115        None => "http://127.0.0.1:<random>".to_string(),
116    }
117}
118
119fn doctor(args: DoctorArgs, file_config: config::FileConfig) -> Result<()> {
120    let channel = resolve_channel(args.channel, file_config.channel)?;
121    let discord_dir = args.discord_dir.or(file_config.discord_dir);
122    let reports = inspect_candidates(channel, discord_dir.as_deref());
123
124    if reports.is_empty() {
125        println!("no candidates found");
126    } else {
127        println!("candidates:");
128        for report in &reports {
129            let status = if report.install().is_some() {
130                "ok"
131            } else {
132                "skip"
133            };
134            let normalized = report
135                .normalized_root()
136                .map(|path| path.display().to_string())
137                .unwrap_or_else(|| "<none>".to_string());
138            let detail = report
139                .install()
140                .map(|install| {
141                    format!(
142                        "app dir: {}, exe: {}",
143                        install.app_dir().display(),
144                        install.exe_name()
145                    )
146                })
147                .or_else(|| report.error().map(str::to_string))
148                .unwrap_or_else(|| "unknown".to_string());
149
150            println!(
151                "- [{status}] {}: {} -> {} ({detail})",
152                report.source(),
153                report.path().display(),
154                normalized
155            );
156        }
157    }
158
159    let install = discover_install(channel, discord_dir.as_deref())?;
160
161    println!("channel: {}", install.channel());
162    println!("source: {}", install.source());
163    println!("root: {}", install.root().display());
164    println!("app dir: {}", install.app_dir().display());
165    if let Some(update_exe) = install.update_exe() {
166        println!("update exe: {}", update_exe.display());
167    } else {
168        println!("update exe: <direct launch>");
169    }
170    println!("exe path: {}", install.exe_path().display());
171    println!("discord exe: {}", install.exe_name());
172
173    Ok(())
174}
175
176async fn run_bridge(args: BridgeArgs) -> Result<()> {
177    let upstream = UpstreamProxy::parse(&args.proxy)?;
178    let bridge = ProxyBridge::start(upstream, args.listen_port)
179        .await
180        .context("failed to start local proxy bridge")?;
181
182    println!("{}", bridge.local_proxy_url());
183    tracing::info!("local proxy bridge is running; press Ctrl+C to stop");
184    tokio::signal::ctrl_c()
185        .await
186        .context("failed to wait for Ctrl+C")?;
187    bridge.shutdown().await
188}
189
190fn resolve_channel(cli: Option<String>, config: Option<String>) -> Result<DiscordChannel> {
191    let value = cli.or(config).unwrap_or_else(|| "stable".to_string());
192    value.parse().map_err(|error| {
193        let valid = DiscordChannel::valid_values().join(", ");
194        anyhow::anyhow!("{error}; valid channels: {valid}")
195    })
196}
197
198pub fn ensure_proxy_is_supported(upstream: &UpstreamProxy) -> Result<()> {
199    if upstream.host().is_empty() {
200        bail!("proxy host cannot be empty");
201    }
202    Ok(())
203}