mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-22 07:08:53 +02:00
fix: first browser download stuck at 0%
This commit is contained in:
1 parent
0789cf0bb9
commit
759263b63f
22 files changed
+291
-49
No files matched your search
@@ -266,6 +266,7 @@ impl ApiClient {
|
||||
let url = "https://donutbrowser.com/wayfern.json";
|
||||
|
||||
let mut last_err = None;
|
||||
let mut proxy_err = None;
|
||||
let mut version_info: Option<WayfernVersionInfo> = None;
|
||||
|
||||
for attempt in 1..=3 {
|
||||
@@ -291,6 +292,7 @@ impl ApiClient {
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Wayfern fetch attempt {attempt}/3 failed: {e}");
|
||||
proxy_err = crate::system_proxy::explain(&e);
|
||||
last_err = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
@@ -301,10 +303,12 @@ impl ApiClient {
|
||||
}
|
||||
|
||||
let version_info = version_info.ok_or_else(|| {
|
||||
format!(
|
||||
"Failed to fetch Wayfern version after 3 attempts: {}",
|
||||
last_err.unwrap_or_default()
|
||||
)
|
||||
proxy_err.unwrap_or_else(|| {
|
||||
format!(
|
||||
"Failed to fetch Wayfern version after 3 attempts: {}",
|
||||
last_err.unwrap_or_default()
|
||||
)
|
||||
})
|
||||
})?;
|
||||
log::info!("Fetched Wayfern version: {}", version_info.version);
|
||||
|
||||
|
||||
@@ -1567,6 +1567,12 @@ pub async fn ensure_active_browsers_downloaded(
|
||||
Ok(info) => info.version,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to resolve current {browser} version: {e}");
|
||||
// The first-run screen listens for this. Without it the screen
|
||||
// waits at 0% for a download that never starts (issue #625).
|
||||
let _ = crate::events::emit(
|
||||
"download-progress",
|
||||
crate::downloader::DownloadProgress::failed(browser, "", Some(e.to_string())),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -1621,6 +1627,10 @@ pub async fn ensure_active_browsers_downloaded(
|
||||
log::warn!(
|
||||
"Failed to auto-download {browser} {version} (attempt {attempt}/{MAX_ATTEMPTS}): {e}"
|
||||
);
|
||||
// A proxy setting stays wrong until the user changes it.
|
||||
if crate::system_proxy::is_unreachable_error(&e.to_string()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// The download future itself hung past the overall timeout and was dropped,
|
||||
@@ -1632,16 +1642,7 @@ pub async fn ensure_active_browsers_downloaded(
|
||||
ATTEMPT_TIMEOUT.as_secs()
|
||||
);
|
||||
crate::downloader::clear_download_state_for_browser(browser);
|
||||
let progress = crate::downloader::DownloadProgress {
|
||||
browser: (*browser).to_string(),
|
||||
version: version.clone(),
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
percentage: 0.0,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "error".to_string(),
|
||||
};
|
||||
let progress = crate::downloader::DownloadProgress::failed(browser, &version, None);
|
||||
let _ = crate::events::emit("download-progress", &progress);
|
||||
}
|
||||
}
|
||||
|
||||
+45
-15
@@ -54,6 +54,26 @@ pub struct DownloadProgress {
|
||||
pub speed_bytes_per_sec: f64,
|
||||
pub eta_seconds: Option<f64>,
|
||||
pub stage: String, // "downloading", "extracting", "verifying"
|
||||
/// Why an "error" stage happened: a coded backend error or plain text.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl DownloadProgress {
|
||||
/// The terminal event for a download that stopped, so the UI can show why.
|
||||
pub fn failed(browser: &str, version: &str, error: Option<String>) -> Self {
|
||||
Self {
|
||||
browser: browser.to_string(),
|
||||
version: version.to_string(),
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
percentage: 0.0,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "error".to_string(),
|
||||
error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Downloader {
|
||||
@@ -268,6 +288,7 @@ impl Downloader {
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "verifying".to_string(),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
verify_archive_checksum(
|
||||
@@ -317,7 +338,7 @@ impl Downloader {
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Checksum sidecar request failed for {browser} {version}: {e}");
|
||||
return Err(unavailable());
|
||||
return Err(crate::system_proxy::explain(&e).map_or_else(unavailable, Into::into));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -416,6 +437,15 @@ impl Downloader {
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
// Through a system proxy, a second connect failure in a row means
|
||||
// the proxy is the problem. Five more attempts would only keep the
|
||||
// bar at 0% for minutes before saying the same thing.
|
||||
if attempt > 0 {
|
||||
if let Some(proxy_error) = crate::system_proxy::explain(&e) {
|
||||
log::warn!("Download request through the system proxy failed: {e}");
|
||||
return Err(proxy_error.into());
|
||||
}
|
||||
}
|
||||
let is_retryable = e.is_connect() || e.is_timeout() || e.is_request();
|
||||
if is_retryable && attempt < max_send_retries {
|
||||
let delay = 2u64.pow(attempt.min(4));
|
||||
@@ -511,6 +541,7 @@ impl Downloader {
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "downloading".to_string(),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -594,6 +625,7 @@ impl Downloader {
|
||||
speed_bytes_per_sec: speed,
|
||||
eta_seconds: eta,
|
||||
stage: "downloading".to_string(),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
last_update = now;
|
||||
@@ -778,24 +810,18 @@ impl Downloader {
|
||||
// Emit a terminal stage so the UI stops spinning. A user cancellation maps to
|
||||
// "cancelled"; any other failure (network error, stall timeout, bad status)
|
||||
// maps to "error" so the frontend can show a concrete error toast.
|
||||
let stage = if cancel_token.is_cancelled() {
|
||||
"cancelled"
|
||||
let failure = contextualize("Failed to download browser", e);
|
||||
let progress = if cancel_token.is_cancelled() {
|
||||
DownloadProgress {
|
||||
stage: "cancelled".to_string(),
|
||||
..DownloadProgress::failed(&browser_str, &version, None)
|
||||
}
|
||||
} else {
|
||||
"error"
|
||||
};
|
||||
let progress = DownloadProgress {
|
||||
browser: browser_str.clone(),
|
||||
version: version.clone(),
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
percentage: 0.0,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: stage.to_string(),
|
||||
DownloadProgress::failed(&browser_str, &version, Some(failure.to_string()))
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
return Err(contextualize("Failed to download browser", e));
|
||||
return Err(failure);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -841,6 +867,7 @@ impl Downloader {
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "error".to_string(),
|
||||
error: None,
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
@@ -862,6 +889,7 @@ impl Downloader {
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "verifying".to_string(),
|
||||
error: None,
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
@@ -913,6 +941,7 @@ impl Downloader {
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "error".to_string(),
|
||||
error: None,
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
@@ -956,6 +985,7 @@ impl Downloader {
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: Some(0.0),
|
||||
stage: "completed".to_string(),
|
||||
error: None,
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ impl ExtractionReporter {
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "extracting".to_string(),
|
||||
error: None,
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
}
|
||||
@@ -273,6 +274,7 @@ impl Extractor {
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "extracting".to_string(),
|
||||
error: None,
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
|
||||
@@ -317,6 +317,36 @@ pub fn lookup_exit_insight(ip: &str) -> ExitInsight {
|
||||
insight
|
||||
}
|
||||
|
||||
/// City, country and country code for `ip`, read from the local City
|
||||
/// database. Proxy checks used to ask ip-api.com over plain HTTP, which showed
|
||||
/// that service and everyone on the path which exit address this machine was
|
||||
/// testing. `None` wherever the database has no answer.
|
||||
pub fn lookup_place(ip: &str) -> (Option<String>, Option<String>, Option<String>) {
|
||||
let Ok(ip_addr) = IpAddr::from_str(ip) else {
|
||||
return (None, None, None);
|
||||
};
|
||||
let Some(path) = GeoIPDownloader::get_mmdb_file_path()
|
||||
.ok()
|
||||
.filter(|path| path.exists())
|
||||
else {
|
||||
return (None, None, None);
|
||||
};
|
||||
let Ok(reader) = Reader::open_readfile(&path) else {
|
||||
return (None, None, None);
|
||||
};
|
||||
let Ok(Some(city)) = reader
|
||||
.lookup(ip_addr)
|
||||
.and_then(|lookup| lookup.decode::<geoip2::City>())
|
||||
else {
|
||||
return (None, None, None);
|
||||
};
|
||||
(
|
||||
first_non_empty([city.city.names.english]),
|
||||
first_non_empty([city.country.names.english]),
|
||||
first_non_empty([city.country.iso_code]).map(|code| code.to_uppercase()),
|
||||
)
|
||||
}
|
||||
|
||||
/// The autonomous system's organisation, from the ASN database that ships
|
||||
/// alongside the city one. Absent on an install that has only ever fetched the
|
||||
/// city database, which is why the caller treats `None` as "unknown".
|
||||
|
||||
@@ -101,6 +101,7 @@ mod settings_manager;
|
||||
pub mod socks5_local;
|
||||
pub mod sync;
|
||||
mod synchronizer;
|
||||
mod system_proxy;
|
||||
pub mod traffic_stats;
|
||||
mod wayfern_cdp;
|
||||
mod wayfern_identity_storage;
|
||||
@@ -1685,10 +1686,7 @@ pub async fn check_vpn_validity_core(
|
||||
|
||||
match ip_utils::fetch_public_ip(Some(&local_proxy_url)).await {
|
||||
Ok(ip) => {
|
||||
let (city, country, country_code) =
|
||||
crate::proxy_manager::ProxyManager::get_ip_geolocation(&ip)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let (city, country, country_code) = crate::geolocation::lookup_place(&ip);
|
||||
|
||||
let insight = crate::geolocation::lookup_exit_insight(&ip);
|
||||
result = Some(crate::proxy_manager::ProxyCheckResult {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! The proxy the operating system hands Donut's own HTTP clients.
|
||||
//!
|
||||
//! reqwest is built with `system-proxy`, so every client that does not call
|
||||
//! `no_proxy()` sends its requests through whatever proxy the environment or,
|
||||
//! on Windows, the WinINet settings name. A proxy left behind by an uninstalled
|
||||
//! VPN or debugging tool therefore breaks Donut's own downloads, and the user
|
||||
//! sees a transfer that never starts (issue #625). This module reads the same
|
||||
//! settings so a failed request can say which proxy it went through.
|
||||
|
||||
/// The proxy used for `https` requests, as `host:port` or a URL. `None` when
|
||||
/// requests go out directly.
|
||||
pub fn https_proxy() -> Option<String> {
|
||||
env_https_proxy(|name| std::env::var(name).ok()).or_else(platform_https_proxy)
|
||||
}
|
||||
|
||||
/// The error a caller returns when a request that went through `proxy` could
|
||||
/// not connect. The frontend turns it into a sentence that names the proxy.
|
||||
pub fn unreachable_error(proxy: &str) -> String {
|
||||
serde_json::json!({
|
||||
"code": "SYSTEM_PROXY_UNREACHABLE",
|
||||
"params": { "proxy": proxy }
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The coded error for a failed request, when the failure is the connection
|
||||
/// itself and a system proxy carried it. `None` for any other failure, which
|
||||
/// the caller reports as before.
|
||||
pub fn explain(error: &reqwest::Error) -> Option<String> {
|
||||
if !(error.is_connect() || error.is_timeout()) {
|
||||
return None;
|
||||
}
|
||||
https_proxy().map(|proxy| unreachable_error(&proxy))
|
||||
}
|
||||
|
||||
/// Whether `message` is the error `unreachable_error` builds. Retrying that one
|
||||
/// only repeats the wait, because the setting stays wrong until the user
|
||||
/// changes it.
|
||||
pub fn is_unreachable_error(message: &str) -> bool {
|
||||
message.contains("\"SYSTEM_PROXY_UNREACHABLE\"")
|
||||
}
|
||||
|
||||
/// The variables reqwest reads, in the order it reads them.
|
||||
fn env_https_proxy(read: impl Fn(&str) -> Option<String>) -> Option<String> {
|
||||
["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"]
|
||||
.into_iter()
|
||||
.filter_map(read)
|
||||
.map(|value| value.trim().to_string())
|
||||
.find(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn platform_https_proxy() -> Option<String> {
|
||||
use winreg::enums::HKEY_CURRENT_USER;
|
||||
use winreg::RegKey;
|
||||
|
||||
let settings = RegKey::predef(HKEY_CURRENT_USER)
|
||||
.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Internet Settings")
|
||||
.ok()?;
|
||||
let enabled: u32 = settings.get_value("ProxyEnable").ok()?;
|
||||
if enabled == 0 {
|
||||
return None;
|
||||
}
|
||||
let server: String = settings.get_value("ProxyServer").ok()?;
|
||||
parse_proxy_server(&server)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn platform_https_proxy() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Read the WinINet `ProxyServer` value. It is either one `host:port` for
|
||||
/// every scheme, or a list such as `http=a:80;https=b:443;socks=c:1080`.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn parse_proxy_server(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !value.contains('=') {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
|
||||
let entry = |scheme: &str| {
|
||||
value.split(';').find_map(|part| {
|
||||
let (name, address) = part.split_once('=')?;
|
||||
let address = address.trim();
|
||||
(name.trim().eq_ignore_ascii_case(scheme) && !address.is_empty()).then(|| address.to_string())
|
||||
})
|
||||
};
|
||||
entry("https").or_else(|| entry("http"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn https_proxy_variables_win_over_all_proxy() {
|
||||
let proxy = env_https_proxy(|name| match name {
|
||||
"https_proxy" => Some("http://127.0.0.1:8080".to_string()),
|
||||
"ALL_PROXY" => Some("socks5://127.0.0.1:1080".to_string()),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(proxy.as_deref(), Some("http://127.0.0.1:8080"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_variables_mean_no_proxy() {
|
||||
assert_eq!(env_https_proxy(|_| Some(" ".to_string())), None);
|
||||
assert_eq!(env_https_proxy(|_| None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_proxy_server_serves_every_scheme() {
|
||||
assert_eq!(
|
||||
parse_proxy_server("127.0.0.1:8888").as_deref(),
|
||||
Some("127.0.0.1:8888")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_per_scheme_list_uses_the_https_entry() {
|
||||
assert_eq!(
|
||||
parse_proxy_server("http=10.0.0.1:80;https=10.0.0.2:443;socks=10.0.0.3:1080").as_deref(),
|
||||
Some("10.0.0.2:443")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_proxy_server("HTTP=10.0.0.1:80").as_deref(),
|
||||
Some("10.0.0.1:80")
|
||||
);
|
||||
assert_eq!(parse_proxy_server("socks=10.0.0.3:1080"), None);
|
||||
assert_eq!(parse_proxy_server(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_unreachable_error_is_recognised() {
|
||||
let error = unreachable_error("127.0.0.1:8888");
|
||||
assert!(is_unreachable_error(&error));
|
||||
assert!(!is_unreachable_error("Download failed after 6 attempts"));
|
||||
let parsed: serde_json::Value = serde_json::from_str(&error).unwrap();
|
||||
assert_eq!(parsed["params"]["proxy"], "127.0.0.1:8888");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user