diff --git a/crates/horizon-port-pack/src/net.rs b/crates/horizon-port-pack/src/net.rs
index d457ae804..d829d1357 100644
--- a/crates/horizon-port-pack/src/net.rs
+++ b/crates/horizon-port-pack/src/net.rs
@@ -2,65 +2,142 @@
//! places as everything else, so they take the same proxy — and leave the same hosts alone.
//!
//! The launcher hands its own setting over (`set_proxy`, from `proxy.rs`); a run of the
-//! `horizon-port-pack` command reads `HTTPS_PROXY`, `HTTP_PROXY` and `ALL_PROXY` instead. Either
-//! way a host in `NO_PROXY`, or on this computer, is reached directly: a host that only answers
-//! without the proxy would otherwise fail here while the launcher's own downloads worked.
+//! `horizon-port-pack` command reads `HTTPS_PROXY`, `HTTP_PROXY` and `ALL_PROXY` instead (`env_for`),
+//! taking the one that matches the address's scheme the way the launcher does. Either way a host in
+//! `NO_PROXY`, or on this computer, is reached directly: a host that only answers without the proxy
+//! would otherwise fail here while the launcher's own downloads worked.
//!
-//! The rules are `launcher/src/proxy.rs`'s, which the launcher applies to its own traffic. The two
-//! crates share no code (the launcher depends on this one, not the other way round), so they are
-//! kept in step by hand.
-use std::sync::Mutex;
+//! Which hosts those are is decided here and nowhere else. The launcher asks `direct`, `env_for`,
+//! `host_of`, `scheme_of`, `socks` and `parse` for its own traffic, and keeps its agents here too
+//! (`keep`), so the two agree whatever the player set — and there is one copy of the rules to
+//! change when they need it.
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Mutex, MutexGuard};
/// What the launcher set, when it did.
static PROXY: Mutex> = Mutex::new(None);
+/// Agents kept for reuse, named by the part that asked and the proxy they use: a fresh `Agent`
+/// starts a new connection pool, so building one per request costs a handshake per file.
+static KEPT: Mutex >> = Mutex::new(None);
+
/// The proxy to use from now on: the launcher's setting, or None to fall back to the environment.
+/// The agents built so far are dropped when it changes, since they hold the address it replaces.
pub fn set_proxy(spec: Option) {
let spec = spec.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
- if let Ok(mut proxy) = PROXY.lock() {
- *proxy = spec;
+ let mut proxy = lock(&PROXY);
+ if *proxy == spec {
+ return;
}
+ *proxy = spec;
+ drop(proxy);
+ *lock(&KEPT) = None;
}
-/// The proxy address to use: the launcher's, else the environment's.
-fn spec() -> Option {
- let set = PROXY.lock().ok().and_then(|proxy| proxy.clone());
- set.or_else(|| {
- [
- "HTTPS_PROXY",
- "https_proxy",
- "HTTP_PROXY",
- "http_proxy",
- "ALL_PROXY",
- "all_proxy",
- ]
- .iter()
- .find_map(|name| {
- std::env::var(name)
- .ok()
- .map(|v| v.trim().to_owned())
- .filter(|v| !v.is_empty())
- })
- })
+/// The proxy address `url` is fetched through: what the launcher set, else the environment's for
+/// its scheme, unless `url` is a host that must be reached directly.
+fn spec_for(url: &str) -> Option {
+ if direct(url) {
+ return None;
+ }
+ lock(&PROXY)
+ .clone()
+ .or_else(|| env_for(scheme_of(url).unwrap_or("https")))
}
/// An agent for downloading `url`: through the proxy when one is set, unless `url` is a host that
/// must be reached directly.
pub fn agent(url: &str) -> ureq::Agent {
- let builder = ureq::AgentBuilder::new();
- if direct(url, &no_proxy()) {
- return builder.build();
+ keep("port-pack", spec_for(url).as_deref(), |proxy| {
+ let builder = ureq::AgentBuilder::new();
+ match proxy {
+ Some(proxy) => builder.proxy(proxy).build(),
+ None => builder.build(),
+ }
+ })
+}
+
+/// The agent `url` is fetched with, built by `build` and then kept for the next request that names
+/// the same `slot` and proxy: a fresh `Agent` starts a new connection pool, so building one per
+/// request is a handshake per file, and a modpack's jars are fetched one after another.
+pub fn keep(
+ slot: &str,
+ spec: Option<&str>,
+ build: impl FnOnce(Option) -> ureq::Agent,
+) -> ureq::Agent {
+ let proxy = spec.and_then(parse);
+ // An address that isn't a proxy is used as none, and remembered as none.
+ let key = format!(
+ "{slot} {}",
+ if proxy.is_some() {
+ spec.unwrap_or_default()
+ } else {
+ ""
+ }
+ );
+ if let Some(agent) = lock(&KEPT).as_ref().and_then(|kept| kept.get(&key)) {
+ return agent.clone();
}
- match spec().and_then(|s| ureq::Proxy::new(s).ok()) {
- Some(proxy) => builder.proxy(proxy).build(),
- None => builder.build(),
+ let agent = build(proxy);
+ lock(&KEPT)
+ .get_or_insert_with(HashMap::new)
+ .insert(key, agent.clone());
+ agent
+}
+
+/// `spec` as a proxy, when it is one. An address that can't be used is said once: a download left
+/// to go direct would otherwise fail with nothing pointing at the proxy.
+pub fn parse(spec: &str) -> Option {
+ if socks(spec) {
+ say(&format!(
+ "{spec} is a SOCKS proxy; downloads go without one. Write the http:// address your \
+ proxy app listens on as well."
+ ));
+ return None;
+ }
+ match ureq::Proxy::new(spec) {
+ Ok(proxy) => Some(proxy),
+ Err(e) => {
+ say(&format!(
+ "The proxy address {spec} can't be used ({e}); downloads go without one."
+ ));
+ None
+ }
}
}
-/// Whether `url` is reached without the proxy, given a `NO_PROXY` list: a local address, or a host
-/// the list names.
-fn direct(url: &str, no_proxy: &str) -> bool {
- host_of(url).is_some_and(|host| local(host) || excluded(host, no_proxy))
+/// Whether `spec` names a SOCKS proxy, which these downloads don't go through: `ureq` reads a
+/// SOCKS address happily and only fails when it connects through it.
+pub fn socks(spec: &str) -> bool {
+ matches!(
+ spec.split_once("://")
+ .map(|(s, _)| s.to_ascii_lowercase())
+ .as_deref(),
+ Some("socks" | "socks4" | "socks4a" | "socks5" | "socks5h")
+ )
+}
+
+/// Whether `url` is reached without the proxy: a local address, or a host `NO_PROXY` names. The
+/// launcher asks this of its own requests, so both reach the same hosts directly.
+pub fn direct(url: &str) -> bool {
+ host_of(url).is_some_and(|host| local(host) || excluded(host, &no_proxy()))
+}
+
+/// `HTTPS_PROXY` (`HTTP_PROXY` for an http address), then `ALL_PROXY`, in the upper-case spelling
+/// first and the lower-case one after, as the tools that write them do.
+pub fn env_for(scheme: &str) -> Option {
+ let names: &[&str] = if scheme.eq_ignore_ascii_case("http") {
+ &["HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"]
+ } else {
+ &["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"]
+ };
+ names.iter().find_map(|name| {
+ std::env::var(name)
+ .ok()
+ .map(|v| v.trim().to_owned())
+ .filter(|v| !v.is_empty())
+ })
}
/// `NO_PROXY`'s list: the hosts reached directly whatever the proxy is.
@@ -72,7 +149,7 @@ fn no_proxy() -> String {
}
/// The host part of a URL (`https://user@host:port/path` is `host`).
-fn host_of(url: &str) -> Option<&str> {
+pub fn host_of(url: &str) -> Option<&str> {
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
let rest = rest.split(['/', '?', '#']).next()?;
let rest = rest.rsplit_once('@').map_or(rest, |(_, host)| host);
@@ -83,7 +160,12 @@ fn host_of(url: &str) -> Option<&str> {
})
}
-/// Whether `host` is this computer.
+/// `https` in `https://…`, or None for anything that isn't an absolute address.
+pub fn scheme_of(url: &str) -> Option<&str> {
+ url.split_once("://").map(|(scheme, _)| scheme)
+}
+
+/// Whether `host` is this computer: the game's own API and a server on the same machine.
fn local(host: &str) -> bool {
let host = bare(host);
host == "localhost"
@@ -119,12 +201,35 @@ fn bare(host: &str) -> String {
host.trim().trim_matches(['[', ']']).to_ascii_lowercase()
}
+/// Said once, however many downloads follow: a wrong address is one thing to report, not one
+/// report per file.
+fn say(what: &str) {
+ static SAID: AtomicBool = AtomicBool::new(false);
+ if !SAID.swap(true, Ordering::Relaxed) {
+ eprintln!("{what}");
+ }
+}
+
+/// The lock, even after a thread panicked while holding it: an address lost to a panic is worse
+/// than an old one kept.
+fn lock(m: &Mutex) -> MutexGuard<'_, T> {
+ m.lock().unwrap_or_else(|e| e.into_inner())
+}
+
#[cfg(test)]
mod tests {
use super::*;
+ /// What the tests below share, since the address and the agents are one of each for the whole
+ /// process: they take it in turn.
+ fn alone() -> MutexGuard<'static, ()> {
+ static TURN: Mutex<()> = Mutex::new(());
+ lock(&TURN)
+ }
+
#[test]
fn a_proxy_the_launcher_sets_is_trimmed_and_kept() {
+ let _turn = alone();
set_proxy(Some(" http://127.0.0.1:7890 ".into()));
assert_eq!(
PROXY.lock().unwrap().as_deref(),
@@ -134,16 +239,13 @@ mod tests {
assert!(PROXY.lock().unwrap().is_none());
}
- /// A host that must be reached directly takes no proxy, however one is set: what an import
- /// with `NO_PROXY=modrinth.com` needs, since the launcher's own requests to it go direct.
+ /// An address on this computer is reached directly whatever `NO_PROXY` says: what an import
+ /// needs when the launcher's own requests to it go direct.
#[test]
- fn a_listed_host_is_reached_directly() {
- let list = "modrinth.com";
- assert!(direct("https://cdn.modrinth.com/data/a.jar", list));
- assert!(direct("https://api.modrinth.com/v2/version", list));
- assert!(!direct("https://edge.forgecdn.net/files/1.jar", list));
- assert!(!direct("https://example.com/a.jar", list));
- assert!(direct("http://127.0.0.1:8130/api/v1/status", ""));
+ fn a_local_address_is_reached_directly() {
+ assert!(direct("http://127.0.0.1:8130/api/v1/status"));
+ assert!(direct("http://localhost/api/v1/status"));
+ assert!(direct("https://[::1]:25565/"));
}
#[test]
@@ -153,32 +255,98 @@ mod tests {
Some("cdn.modrinth.com")
);
assert_eq!(
- host_of("https://user:pass@host.example:8443/a"),
- Some("host.example")
+ host_of("https://user:pass@piston-meta.mojang.com:8443/a"),
+ Some("piston-meta.mojang.com")
+ );
+ assert_eq!(
+ host_of("http://127.0.0.1:8130/api/v1/status"),
+ Some("127.0.0.1")
);
assert_eq!(host_of("https://[::1]:25565/"), Some("[::1]"));
+ assert_eq!(host_of("https://example.com?x=1"), Some("example.com"));
+ assert_eq!(host_of("https://example.com#top"), Some("example.com"));
+ }
+
+ #[test]
+ fn only_absolute_addresses_have_a_scheme() {
+ assert_eq!(scheme_of("https://a.b"), Some("https"));
+ assert_eq!(scheme_of("http://a.b"), Some("http"));
+ assert_eq!(scheme_of("a.b/c"), None);
}
#[test]
fn this_computer_is_never_proxied() {
- for host in ["localhost", "[::1]", "127.0.0.1", "127.1.2.3"] {
+ for host in [
+ "localhost",
+ "LOCALHOST",
+ "[::1]",
+ "::1",
+ "127.0.0.1",
+ "127.1.2.3",
+ ] {
assert!(local(host), "{host}");
}
- for host in ["cdn.modrinth.com", "10.0.0.1"] {
+ for host in ["playhorizon.dev", "api.minecraftservices.com", "10.0.0.1"] {
assert!(!local(host), "{host}");
}
}
+ /// What an import with `NO_PROXY=modrinth.com` needs: the launcher's own requests to it go
+ /// direct, so the jars fetched for it must too.
#[test]
fn no_proxy_lists_hosts_and_their_subdomains() {
assert!(excluded("anything.at.all", "*"));
assert!(excluded("cdn.modrinth.com", "modrinth.com"));
+ assert!(excluded("api.modrinth.com", "modrinth.com"));
+ assert!(excluded("piston-meta.mojang.com", "mojang.com"));
assert!(excluded(
"wiki.corp.example",
" .corp.example , other.host "
));
assert!(excluded("github.com", "github.com:443"));
assert!(!excluded("notmodrinth.com", "modrinth.com"));
+ assert!(!excluded("modrinth.com", " .corp.example , mojang.com "));
+ assert!(!excluded("edge.forgecdn.net", "modrinth.com"));
assert!(!excluded("modrinth.com", ""));
}
+
+ #[test]
+ fn an_address_that_cant_be_used_becomes_no_proxy() {
+ assert!(parse("http://127.0.0.1:7890").is_some());
+ assert!(parse("127.0.0.1:7890").is_some());
+ // `ureq` reads a SOCKS address and only fails once it connects through it.
+ assert!(socks("socks5://127.0.0.1:7891"));
+ assert!(parse("socks5://127.0.0.1:7891").is_none());
+ assert!(!socks("http://127.0.0.1:7890"));
+ }
+
+ /// The agent a second request gets is the one the first built: one connection pool, not one
+ /// handshake per jar.
+ #[test]
+ fn an_agent_is_kept_for_the_next_request() {
+ let _turn = alone();
+ set_proxy(None);
+ let mut built = 0;
+ for _ in 0..3 {
+ keep("kept", None, |_| {
+ built += 1;
+ ureq::AgentBuilder::new().build()
+ });
+ }
+ assert_eq!(built, 1, "three requests, one agent");
+ // Another address is another agent, and a new setting drops the ones before it.
+ keep("kept", Some("http://127.0.0.1:7890"), |proxy| {
+ assert!(proxy.is_some());
+ built += 1;
+ ureq::AgentBuilder::new().build()
+ });
+ assert_eq!(built, 2, "a second address needs a second agent");
+ set_proxy(Some("http://127.0.0.1:7891".into()));
+ keep("kept", Some("http://127.0.0.1:7891"), |_| {
+ built += 1;
+ ureq::AgentBuilder::new().build()
+ });
+ assert_eq!(built, 3, "a new setting drops the agents kept before it");
+ set_proxy(None);
+ }
}
diff --git a/docs/launcher-commands.md b/docs/launcher-commands.md
index 7c9562441..9e315165d 100644
--- a/docs/launcher-commands.md
+++ b/docs/launcher-commands.md
@@ -132,11 +132,11 @@ MCP: `account_offline`.
## `horizon-launcher settings`
-Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off.
+Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off, proxy http://host:port ("default" removes it).
horizon-launcher settings [KEY] [VALUE]
-- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation)
+- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation, proxy)
- `VALUE`: The new value (with KEY)
MCP: `settings`.
diff --git a/launcher/src/browser/download.rs b/launcher/src/browser/download.rs
index 580cc5d28..f370234f8 100644
--- a/launcher/src/browser/download.rs
+++ b/launcher/src/browser/download.rs
@@ -19,18 +19,24 @@ const MAX: u64 = 1 << 30;
/// The HTTP agent the sources share, reaching `url` through the player's proxy when one is set
/// (`proxy`). Modrinth asks for a user agent that names the project.
+///
+/// It is kept between downloads: a fresh agent starts a new connection pool, so one per file would
+/// be a handshake per file, and a modpack brings hundreds.
pub(super) fn agent(url: &str) -> ureq::Agent {
- crate::proxy::agent(
- ureq::AgentBuilder::new()
+ let spec = crate::proxy::spec_for(url);
+ horizon_port_pack::net::keep("browser", spec.as_deref(), |proxy| {
+ let builder = ureq::AgentBuilder::new()
.user_agent(&format!(
"ludovic111/horizon horizon-launcher/{} (github.com/ludovic111/horizon)",
crate::backend::VERSION
))
.timeout_connect(std::time::Duration::from_secs(15))
- .timeout_read(std::time::Duration::from_secs(60)),
- url,
- )
- .build()
+ .timeout_read(std::time::Duration::from_secs(60));
+ match proxy {
+ Some(proxy) => builder.proxy(proxy).build(),
+ None => builder.build(),
+ }
+ })
}
/// Why a file can't be downloaded at all, before any byte is read.
diff --git a/launcher/src/cli.rs b/launcher/src/cli.rs
index f7c934e59..8f9f4001d 100644
--- a/launcher/src/cli.rs
+++ b/launcher/src/cli.rs
@@ -731,7 +731,7 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
let value = value.ok_or("settings KEY VALUE: the value is missing")?;
set_setting(&mut settings, &key, &value)?;
settings.save();
- horizon_launcher::proxy::announce();
+ horizon_launcher::proxy::announce(settings.proxy.clone());
}
let all = settings_json(&settings);
if as_json {
diff --git a/launcher/src/main.rs b/launcher/src/main.rs
index 5abb732c3..b9a7665d1 100644
--- a/launcher/src/main.rs
+++ b/launcher/src/main.rs
@@ -2721,7 +2721,9 @@ impl Launcher {
}
_ if paste => {
if let Some(text) = cx.read_from_clipboard().and_then(|c| c.text()) {
- this.proxy_input.push_str(text.trim());
+ // A copied line, not a copied page: the field takes one address.
+ let line = text.lines().next().unwrap_or(&text);
+ this.proxy_input.push_str(line.trim());
}
}
_ if !k.modifiers.control && !k.modifiers.platform => {
@@ -2731,7 +2733,10 @@ impl Launcher {
}
_ => return,
}
- this.proxy_input.truncate(256);
+ // `truncate` counts bytes, and a cut inside a character panics: count characters.
+ if this.proxy_input.chars().count() > 256 {
+ this.proxy_input = this.proxy_input.chars().take(256).collect();
+ }
this.save_proxy();
cx.stop_propagation();
cx.notify();
@@ -2756,7 +2761,7 @@ impl Launcher {
let value = self.proxy_input.trim().to_owned();
self.settings.proxy = (!value.is_empty()).then_some(value);
self.settings.save();
- horizon_launcher::proxy::announce();
+ horizon_launcher::proxy::announce(self.settings.proxy.clone());
}
/// Settings' Microsoft accounts: who is signed in and plays, the sign-in with its code. The
@@ -3672,7 +3677,7 @@ fn main() {
backend::exe_path();
// What the launcher downloads goes through the player's proxy, if they set one: the parts
// that build their own agents (`horizon-port-pack`) are told before anything starts.
- horizon_launcher::proxy::announce();
+ horizon_launcher::proxy::announce(horizon_launcher::backend::Settings::load().proxy);
if let Some(code) = cli::run() {
std::process::exit(code);
}
diff --git a/launcher/src/proxy.rs b/launcher/src/proxy.rs
index 730f1b4b7..8d6493456 100644
--- a/launcher/src/proxy.rs
+++ b/launcher/src/proxy.rs
@@ -9,23 +9,35 @@
//! Servers page's calls to a game server's API (`servers.rs`) build their agent without it: they
//! are on loopback and asked every second.
//!
-//! The jars an import downloads are fetched by `horizon-port-pack`, which builds its own agents and
-//! keeps these same `NO_PROXY` rules (`crates/horizon-port-pack/src/net.rs`). The two crates share
-//! no code, so a change to them here belongs there too.
+//! Which hosts are reached directly, and how an address is read, are `horizon-port-pack`'s rules
+//! (`crates/horizon-port-pack/src/net.rs`): the jars an import fetches are downloaded there, and
+//! the same hosts have to be left alone on both sides. This module adds what is the launcher's
+//! own — the setting in `settings.json`, kept in memory so a request doesn't read the file — and
+//! hands the address to the parts that build their agents.
use crate::backend::Settings;
+use std::sync::Mutex;
-/// The proxy to reach `url` through: the one set for the launcher or in the environment, unless
+/// The launcher's setting, as `announce` last read it, or None before it has: a request shouldn't
+/// read `settings.json` every time, and an import fetches hundreds of jars.
+static SET: Mutex>> = Mutex::new(None);
+
+/// The proxy address to reach `url` through: the launcher's setting, else the environment's, unless
/// `url` is a local address or `NO_PROXY` names its host.
-pub fn for_url(url: &str) -> Option {
- let scheme = scheme_of(url)?;
- let host = host_of(url)?;
- if local(host) || excluded(host, &no_proxy()) {
+pub fn spec_for(url: &str) -> Option {
+ let scheme = horizon_port_pack::net::scheme_of(url)?;
+ if horizon_port_pack::net::direct(url) {
return None;
}
- ureq::Proxy::new(written(scheme)?).ok()
+ setting().or_else(|| horizon_port_pack::net::env_for(scheme))
}
-/// `builder`, reaching `url` through the proxy when one is set.
+/// The proxy to reach `url` through: `spec_for`'s address, read as one.
+pub fn for_url(url: &str) -> Option {
+ spec_for(url).and_then(|spec| horizon_port_pack::net::parse(&spec))
+}
+
+/// `builder`, reaching `url` through the proxy when one is set. For the parts that ask for a few
+/// addresses; the ones that fetch file after file keep their agent instead (`keep`).
pub fn agent(builder: ureq::AgentBuilder, url: &str) -> ureq::AgentBuilder {
match for_url(url) {
Some(proxy) => builder.proxy(proxy),
@@ -39,19 +51,17 @@ pub fn refuse(spec: &str) -> Option {
if spec.is_empty() {
return None;
}
- let scheme = spec.split_once("://").map(|(s, _)| s.to_ascii_lowercase());
- if matches!(
- scheme.as_deref(),
- Some("socks" | "socks4" | "socks4a" | "socks5" | "socks5h")
- ) {
+ if horizon_port_pack::net::socks(spec) {
return Some(format!(
"{spec} is a SOCKS proxy; Horizon downloads through an HTTP proxy, such as the \
http:// address your proxy app also listens on."
));
}
- // `Proxy::new` takes `http://` without a host, which would leave a download failing with
- // nothing said about the proxy.
- if ureq::Proxy::new(spec).is_err() || host_of(spec).is_none_or(str::is_empty) {
+ // `ureq` takes `http://` without a host, which would leave a download failing with nothing
+ // said about the proxy.
+ if horizon_port_pack::net::parse(spec).is_none()
+ || horizon_port_pack::net::host_of(spec).is_none_or(str::is_empty)
+ {
return Some(format!(
"{spec} isn't a proxy address. Write it as http://host:port."
));
@@ -59,97 +69,46 @@ pub fn refuse(spec: &str) -> Option {
None
}
-/// Tells the parts that build their own agents (`horizon-port-pack`) which proxy to use. Called at
-/// start and whenever the player changes the setting; the hosts `NO_PROXY` names stay direct there
-/// too, since it reads the same list.
-pub fn announce() {
- horizon_port_pack::net::set_proxy(written("https"));
+/// Tells the parts that build their own agents (`horizon-port-pack`) which proxy to use, and keeps
+/// the setting for the launcher's own requests. Called with what `settings.json` holds: at start,
+/// and whenever the player changes the setting.
+///
+/// The environment's is left to them: they read it per address (`HTTPS_PROXY` for an https jar,
+/// `HTTP_PROXY` for an http one), the way the launcher does for its own.
+pub fn announce(setting: Option) {
+ let spec = setting
+ .map(|p| p.trim().to_owned())
+ .filter(|p| !p.is_empty());
+ *held() = Some(spec.clone());
+ horizon_port_pack::net::set_proxy(spec);
}
-/// The proxy as it is written down: the launcher's, else the environment's for `scheme`.
-fn written(scheme: &str) -> Option {
+/// The launcher's setting: read from `settings.json` the first time it is needed and kept after
+/// that, so a download doesn't read the file once per file.
+fn setting() -> Option {
+ let mut set = held();
+ match set.clone() {
+ Some(kept) => kept,
+ None => {
+ let read = written();
+ *set = Some(read.clone());
+ read
+ }
+ }
+}
+
+/// The launcher's setting as it is written down, or None when there is none.
+fn written() -> Option {
Settings::load()
.proxy
.map(|p| p.trim().to_owned())
.filter(|p| !p.is_empty())
- .or_else(|| from_env(scheme))
}
-/// `HTTPS_PROXY` (`HTTP_PROXY` for an http address), then `ALL_PROXY`, in the upper-case spelling
-/// first and the lower-case one after, as the tools that write them do.
-fn from_env(scheme: &str) -> Option {
- let names: &[&str] = if scheme.eq_ignore_ascii_case("http") {
- &["HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"]
- } else {
- &["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"]
- };
- names.iter().find_map(|name| {
- std::env::var(name)
- .ok()
- .map(|v| v.trim().to_owned())
- .filter(|v| !v.is_empty())
- })
-}
-
-/// `NO_PROXY`'s list: the hosts reached directly whatever the proxy is.
-fn no_proxy() -> String {
- ["NO_PROXY", "no_proxy"]
- .iter()
- .find_map(|name| std::env::var(name).ok())
- .unwrap_or_default()
-}
-
-/// The host part of a URL (`https://user@host:port/path` is `host`).
-fn host_of(url: &str) -> Option<&str> {
- let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
- let rest = rest.split(['/', '?', '#']).next()?;
- let rest = rest.rsplit_once('@').map_or(rest, |(_, host)| host);
- Some(match rest.rfind(':') {
- // A port follows the last colon; an IPv6 literal keeps the colons inside its brackets.
- Some(at) if !rest[at..].contains(']') => &rest[..at],
- _ => rest,
- })
-}
-
-/// `https` in `https://…`, or None for anything that isn't an absolute address.
-fn scheme_of(url: &str) -> Option<&str> {
- url.split_once("://").map(|(scheme, _)| scheme)
-}
-
-/// Whether `host` is this computer: the game's own API and a server on the same machine.
-fn local(host: &str) -> bool {
- let host = bare(host);
- host == "localhost"
- || host.ends_with(".localhost")
- || host == "::1"
- || host == "0.0.0.0"
- || host.starts_with("127.")
-}
-
-/// Whether `host` is in `NO_PROXY`: `*`, `host`, `.domain`, `*.domain` and `host:port` entries.
-fn excluded(host: &str, no_proxy: &str) -> bool {
- let host = bare(host);
- no_proxy
- .split(',')
- .map(str::trim)
- .filter(|e| !e.is_empty())
- .any(|entry| {
- if entry == "*" {
- return true;
- }
- let entry = entry.trim_start_matches('*').trim_start_matches('.');
- let entry = match entry.rfind(':') {
- Some(at) if !entry[at..].contains(']') => &entry[..at],
- _ => entry,
- };
- let entry = bare(entry);
- !entry.is_empty() && (host == entry || host.ends_with(&format!(".{entry}")))
- })
-}
-
-/// A host without the brackets an IPv6 literal wears, lower case.
-fn bare(host: &str) -> String {
- host.trim().trim_matches(['[', ']']).to_ascii_lowercase()
+/// The setting's lock, even after a thread panicked while holding it: an address lost to a panic
+/// is worse than an old one kept.
+fn held() -> std::sync::MutexGuard<'static, Option>> {
+ SET.lock().unwrap_or_else(|e| e.into_inner())
}
#[cfg(test)]
@@ -157,58 +116,15 @@ mod tests {
use super::*;
#[test]
- fn a_host_is_read_out_of_the_address() {
- assert_eq!(
- host_of("https://cdn.modrinth.com/v2/x.jar"),
- Some("cdn.modrinth.com")
- );
- assert_eq!(
- host_of("https://user:pass@piston-meta.mojang.com:8443/a"),
- Some("piston-meta.mojang.com")
- );
- assert_eq!(
- host_of("http://127.0.0.1:8130/api/v1/status"),
- Some("127.0.0.1")
- );
- assert_eq!(host_of("https://[::1]:25565/"), Some("[::1]"));
- assert_eq!(host_of("https://example.com?x=1"), Some("example.com"));
- assert_eq!(host_of("https://example.com#top"), Some("example.com"));
+ fn a_local_address_takes_no_proxy() {
+ assert_eq!(spec_for("http://127.0.0.1:8130/api/v1/status"), None);
+ assert_eq!(spec_for("http://localhost/api/v1/status"), None);
}
+ /// An address the launcher knows nothing about is one nothing is written for either.
#[test]
- fn only_absolute_addresses_have_a_scheme() {
- assert_eq!(scheme_of("https://a.b"), Some("https"));
- assert_eq!(scheme_of("http://a.b"), Some("http"));
- assert_eq!(scheme_of("a.b/c"), None);
- }
-
- #[test]
- fn this_computer_is_never_proxied() {
- for host in [
- "localhost",
- "LOCALHOST",
- "[::1]",
- "::1",
- "127.0.0.1",
- "127.1.2.3",
- ] {
- assert!(local(host), "{host}");
- }
- for host in ["playhorizon.dev", "api.minecraftservices.com", "10.0.0.1"] {
- assert!(!local(host), "{host}");
- }
- }
-
- #[test]
- fn no_proxy_lists_hosts_and_their_subdomains() {
- let list = "*.internal, .corp.example, mojang.com, github.com:443, *";
- assert!(excluded("anything.at.all", list));
- assert!(excluded("wiki.corp.example", "corp.example"));
- assert!(excluded("piston-meta.mojang.com", "mojang.com"));
- assert!(excluded("github.com", "github.com:443"));
- assert!(!excluded("notmojang.com", "mojang.com"));
- assert!(!excluded("modrinth.com", " .corp.example , mojang.com "));
- assert!(!excluded("modrinth.com", ""));
+ fn a_relative_address_has_no_scheme_to_read_a_proxy_for() {
+ assert_eq!(spec_for("data/a.jar"), None);
}
#[test]