From 9d4e778c7b360d61cfc17b3f7b70d96e8f453920 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:43:30 +0800 Subject: [PATCH] feat(settings): HTTP Proxy settings for the IDE's outgoing connections (#194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): HTTP Proxy settings for the IDE's outgoing connections Add Appearance & Behavior › System Settings › HTTP Proxy with three modes: No proxy, Auto-detect proxy settings (the default; environment proxy as before) and Manual proxy configuration (HTTP or SOCKS, host and port, a comma-separated "No proxy for" list with `*` wildcards, optional proxy authentication). "Check connection…" GETs a URL with the settings as edited and reports the status or the error. - ide-core: `ProxySettings` plus routing (no-proxy globs, loopback always direct, credentials percent-encoded), the current proxy for the process, curl arguments and child-process variables. - ide-http: `client::build` / `client::shared` is the one HTTP client constructor; the HTTP Client requests and the update check go through it. - ide-tools: curl downloads (tools, JDKs, debug adapters, agent registry, remote server) follow the setting. - A manual proxy reaches child processes (language servers, debug adapters, terminals, git) as HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY. - The proxy password is kept in the owner-only saved passwords file (`proxy-password.json` in the app data directory), not in settings.toml. Co-Authored-By: Claude * fix(host): Allow the Unix-only post-start test helpers on Windows Co-Authored-By: Claude --------- Co-authored-by: Claude --- Cargo.toml | 2 +- crates/ide-core/src/lib.rs | 1 + crates/ide-core/src/process.rs | 9 +- crates/ide-core/src/proxy_settings.rs | 365 ++++++++++++++++++ crates/ide-core/src/settings.rs | 4 + crates/ide-host/src/environment.rs | 4 + crates/ide-host/src/lib.rs | 10 +- crates/ide-host/src/ops/project.rs | 17 +- crates/ide-host/src/proxy.rs | 50 +++ crates/ide-host/tests/proxy.rs | 74 ++++ crates/ide-http/src/client.rs | 187 +++++++++ crates/ide-http/src/exec.rs | 43 +-- crates/ide-http/src/lib.rs | 4 +- crates/ide-ssh/src/secrets.rs | 12 +- crates/ide-tools/src/process.rs | 2 + src-tauri/src/commands/app.rs | 8 +- src-tauri/src/commands/http.rs | 8 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/window.rs | 3 +- src/bindings.ts | 33 ++ src/features/settings/model/schema.ts | 5 + .../settings/pages/HttpProxy.module.css | 88 +++++ src/features/settings/pages/behavior.tsx | 4 +- src/features/settings/pages/httpProxy.tsx | 297 ++++++++++++++ src/platform/locales/text/zh.json | 25 +- src/platform/locales/textCatalog.test.ts | 2 + src/platform/proxySettings.test.ts | 68 ++++ src/platform/proxySettings.ts | 82 ++++ 28 files changed, 1343 insertions(+), 65 deletions(-) create mode 100644 crates/ide-core/src/proxy_settings.rs create mode 100644 crates/ide-host/src/proxy.rs create mode 100644 crates/ide-host/tests/proxy.rs create mode 100644 crates/ide-http/src/client.rs create mode 100644 src/features/settings/pages/HttpProxy.module.css create mode 100644 src/features/settings/pages/httpProxy.tsx create mode 100644 src/platform/proxySettings.test.ts create mode 100644 src/platform/proxySettings.ts diff --git a/Cargo.toml b/Cargo.toml index dd835d82..d191dac1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,7 +87,7 @@ tree-sitter = "0.25" lsp-types = "0.97" gix = { version = "0.89", default-features = false } portable-pty = "0.9" -reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "gzip", "deflate", "brotli"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "socks", "gzip", "deflate", "brotli"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } rustls-platform-verifier = "0.7" sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] } diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index 905a5cb7..c348c23d 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod ipc_path; pub mod jsonc; pub mod process; pub mod project_dir; +pub mod proxy_settings; pub mod remote_settings; pub mod settings; pub mod task; diff --git a/crates/ide-core/src/process.rs b/crates/ide-core/src/process.rs index dd921258..9f789d85 100644 --- a/crates/ide-core/src/process.rs +++ b/crates/ide-core/src/process.rs @@ -30,7 +30,8 @@ pub fn prepare(cmd: &mut Command) -> &mut Command { /// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that /// inherits them loads the image's (older) libraries and plugins next to its own, e.g. /// `curl: symbol lookup error ... nghttp2_...`. The IDE process keeps its environment -/// (WebKit and GTK still need it). Then applies the [`set_env_overlay`] variables. +/// (WebKit and GTK still need it). Then applies the [`set_env_overlay`] variables and +/// those of a manual HTTP proxy ([`crate::proxy_settings::ProxySettings::child_env`]). /// Variables the call site has already set are kept. A no-op outside an AppImage /// without an overlay. pub fn host_env(cmd: &mut Command) -> &mut Command { @@ -89,12 +90,16 @@ fn apply_changes( vars } -/// The AppImage fixes, then the overlay (which wins for a name both change); one entry per name. +/// The AppImage fixes, the overlay, then the manual proxy's (a later one wins); one entry per name. fn child_env_changes() -> Vec<(OsString, Option)> { let mut changes = appimage_fixes(); let overlay = env_overlay(); changes.retain(|(k, _)| !overlay.iter().any(|(o, _)| o == k)); changes.extend(overlay.iter().cloned()); + // A manual HTTP proxy reaches tools that read `HTTP_PROXY` (language servers, git, curl). + let proxy = crate::proxy_settings::proxy().child_env(); + changes.retain(|(k, _)| !proxy.iter().any(|(p, _)| p == k)); + changes.extend(proxy); changes } diff --git a/crates/ide-core/src/proxy_settings.rs b/crates/ide-core/src/proxy_settings.rs new file mode 100644 index 00000000..564c2df9 --- /dev/null +++ b/crates/ide-core/src/proxy_settings.rs @@ -0,0 +1,365 @@ +//! Appearance & Behavior › System Settings › HTTP Proxy, and the proxy the IDE's own +//! connections use: [`set_proxy`] makes a configuration current, every outgoing +//! request asks [`proxy`] (the HTTP client, `curl` downloads, the updater), and with a +//! manual proxy child processes get `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`. + +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::ffi::OsString; +use std::sync::{Arc, RwLock}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ProxyMode { + /// Every connection goes direct. + None, + /// The system's proxy: `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` of the environment. + #[default] + Auto, + /// The proxy configured here. + Manual, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ProxyKind { + #[default] + Http, + Socks, +} + +impl ProxyKind { + pub fn default_port(self) -> u16 { + match self { + ProxyKind::Http => 80, + ProxyKind::Socks => 1080, + } + } + + fn scheme(self) -> &'static str { + match self { + ProxyKind::Http => "http", + // Host names resolve on the proxy. + ProxyKind::Socks => "socks5h", + } + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct ProxySettings { + pub mode: ProxyMode, + pub kind: ProxyKind, + pub host: String, + pub port: u16, + /// Hosts reached directly, comma-separated; `*` matches any characters (`*.example.com`, `192.168.*`). + pub no_proxy: String, + /// Send `login` / `password` to the proxy. + pub authenticate: bool, + pub login: String, + /// Kept in the saved passwords rather than `settings.toml`; filled in when settings are read. + pub password: String, +} + +impl Default for ProxySettings { + fn default() -> Self { + Self { + mode: ProxyMode::Auto, + kind: ProxyKind::Http, + host: String::new(), + port: ProxyKind::Http.default_port(), + no_proxy: String::new(), + authenticate: false, + login: String::new(), + password: String::new(), + } + } +} + +impl std::fmt::Debug for ProxySettings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProxySettings") + .field("mode", &self.mode) + .field("kind", &self.kind) + .field("host", &self.host) + .field("port", &self.port) + .field("no_proxy", &self.no_proxy) + .field("authenticate", &self.authenticate) + .field("login", &self.login) + .field("password", &if self.password.is_empty() { "" } else { "***" }) + .finish() + } +} + +/// How a connection to some host is made. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProxyRoute { + Direct, + /// Whatever the environment says (the HTTP library's / `curl`'s own proxy lookup). + System, + /// Through this proxy URL, credentials included (`http://user:pass@proxy:3128`). + Via(String), +} + +/// Loopback hosts never go through the manual proxy. +const LOOPBACK: [&str; 3] = ["localhost", "127.0.0.1", "::1"]; + +impl ProxySettings { + /// A manual proxy with a host: what [`Self::route`] sends connections through. + pub fn manual(&self) -> bool { + self.mode == ProxyMode::Manual && !self.host.trim().is_empty() + } + + /// What is wrong with the manual configuration, if anything. + pub fn problem(&self) -> Option { + if self.mode != ProxyMode::Manual { + return None; + } + let host = self.host.trim(); + if host.is_empty() { + return Some("Proxy host is empty".into()); + } + if host.contains(['/', '@', ' ']) { + return Some(format!("Invalid proxy host: {host}")); + } + if self.port == 0 { + return Some("Proxy port must be between 1 and 65535".into()); + } + if self.authenticate && self.login.trim().is_empty() { + return Some("Proxy login is empty".into()); + } + None + } + + /// Whether `host` is reached directly: loopback, or a "No proxy for" entry. + pub fn bypasses(&self, host: &str) -> bool { + let host = host.trim().trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase(); + if LOOPBACK.contains(&host.as_str()) { + return true; + } + self.no_proxy_entries().any(|pattern| glob_match(&pattern, &host)) + } + + fn no_proxy_entries(&self) -> impl Iterator + '_ { + self.no_proxy.split([',', ';', '\n']).map(|e| e.trim().to_ascii_lowercase()).filter(|e| !e.is_empty()) + } + + /// The manual proxy as a URL, with the credentials when it authenticates. + pub fn proxy_url(&self) -> Option { + if !self.manual() { + return None; + } + let host = self.host.trim(); + let host = if host.contains(':') && !host.starts_with('[') { format!("[{host}]") } else { host.to_owned() }; + let auth = if self.authenticate && !self.login.is_empty() { + format!("{}:{}@", encode_userinfo(&self.login), encode_userinfo(&self.password)) + } else { + String::new() + }; + Some(format!("{}://{auth}{host}:{}", self.kind.scheme(), self.port)) + } + + /// How to connect to `host` (`None`: not known yet, e.g. the request has no URL). + pub fn route(&self, host: Option<&str>) -> ProxyRoute { + match self.mode { + ProxyMode::None => ProxyRoute::Direct, + ProxyMode::Auto => ProxyRoute::System, + ProxyMode::Manual => match self.proxy_url() { + Some(_) if host.is_some_and(|h| self.bypasses(h)) => ProxyRoute::Direct, + Some(url) => ProxyRoute::Via(url), + None => ProxyRoute::Direct, + }, + } + } + + /// [`Self::route`] for the host of `url` (`https://user@host:443/path`). + pub fn route_for_url(&self, url: &str) -> ProxyRoute { + self.route(url_host(url).as_deref()) + } + + /// Arguments that make a `curl` download of `url` follow this configuration. The + /// manual proxy itself comes from the environment (see [`Self::child_env`]), which + /// keeps the credentials out of the command line. + pub fn curl_args(&self, url: &str) -> Vec<&'static str> { + match self.route_for_url(url) { + ProxyRoute::Direct => vec!["--noproxy", "*"], + ProxyRoute::System => Vec::new(), + // Only this configuration decides what bypasses the proxy. + ProxyRoute::Via(_) => vec!["--noproxy", ""], + } + } + + /// Variables child processes get with a manual proxy (`HTTP_PROXY`, `HTTPS_PROXY`, + /// `ALL_PROXY`, `NO_PROXY` and their lowercase forms); none otherwise. + pub fn child_env(&self) -> Vec<(OsString, Option)> { + let Some(url) = self.proxy_url() else { return Vec::new() }; + let mut no_proxy: Vec = LOOPBACK.iter().map(|h| (*h).to_owned()).collect(); + // `*.example.com` is `.example.com` to curl, Go, Python and Node. + no_proxy.extend(self.no_proxy_entries().map(|e| e.strip_prefix('*').map(str::to_owned).unwrap_or(e))); + no_proxy.dedup(); + let no_proxy = no_proxy.join(","); + let mut env = Vec::new(); + for name in ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"] { + env.push((OsString::from(name), Some(OsString::from(&url)))); + env.push((OsString::from(name.to_ascii_lowercase()), Some(OsString::from(&url)))); + } + env.push((OsString::from("NO_PROXY"), Some(OsString::from(&no_proxy)))); + env.push((OsString::from("no_proxy"), Some(OsString::from(no_proxy)))); + env + } +} + +/// Case-insensitive host glob (`pattern` is lowercase): `*` matches any run of characters. +fn glob_match(pattern: &str, host: &str) -> bool { + let (p, h) = (pattern.as_bytes(), host.as_bytes()); + let (mut pi, mut hi) = (0, 0); + let mut star: Option<(usize, usize)> = None; + while hi < h.len() { + if pi < p.len() && p[pi] == b'*' { + star = Some((pi, hi)); + pi += 1; + } else if pi < p.len() && p[pi] == h[hi] { + pi += 1; + hi += 1; + } else if let Some((sp, sh)) = star { + pi = sp + 1; + hi = sh + 1; + star = Some((sp, sh + 1)); + } else { + return false; + } + } + p[pi..].iter().all(|&c| c == b'*') +} + +fn encode_userinfo(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +/// The host of `url`, without brackets, userinfo or port. +fn url_host(url: &str) -> Option { + let rest = url.split_once("://").map_or(url, |(_, r)| r); + let authority = rest.split(['/', '?', '#']).next()?; + let authority = authority.rsplit_once('@').map_or(authority, |(_, a)| a); + let host = + if let Some(v6) = authority.strip_prefix('[') { v6.split(']').next()? } else { authority.split(':').next()? }; + (!host.is_empty()).then(|| host.to_ascii_lowercase()) +} + +static CURRENT: RwLock>> = RwLock::new(None); + +/// Makes `settings` (password filled in) the proxy of every later connection and child process. +pub fn set_proxy(settings: ProxySettings) { + *CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(settings)); +} + +/// The current proxy configuration (the default, auto-detect, until [`set_proxy`]). +pub fn proxy() -> Arc { + CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manual(host: &str, port: u16) -> ProxySettings { + ProxySettings { mode: ProxyMode::Manual, host: host.into(), port, ..Default::default() } + } + + #[test] + fn auto_is_the_default_and_defers_to_the_system() { + let p = ProxySettings::default(); + assert_eq!(p.mode, ProxyMode::Auto); + assert_eq!(p.route_for_url("https://example.com/"), ProxyRoute::System); + assert!(p.curl_args("https://example.com/").is_empty()); + assert!(p.child_env().is_empty()); + assert_eq!(p.problem(), None); + } + + #[test] + fn no_proxy_goes_direct() { + let p = ProxySettings { mode: ProxyMode::None, ..manual("proxy", 3128) }; + assert_eq!(p.route_for_url("https://example.com/"), ProxyRoute::Direct); + assert_eq!(p.curl_args("https://example.com/"), ["--noproxy", "*"]); + assert!(p.child_env().is_empty()); + } + + #[test] + fn manual_http_and_socks_urls() { + let p = manual("proxy.corp", 3128); + assert_eq!(p.route_for_url("https://example.com/x"), ProxyRoute::Via("http://proxy.corp:3128".into())); + assert_eq!(p.curl_args("https://example.com/x"), ["--noproxy", ""]); + let socks = ProxySettings { kind: ProxyKind::Socks, ..manual("10.0.0.1", 1080) }; + assert_eq!(socks.proxy_url().as_deref(), Some("socks5h://10.0.0.1:1080")); + assert_eq!(manual("::1", 8080).proxy_url().as_deref(), Some("http://[::1]:8080")); + // Manual without a host connects directly. + assert_eq!(manual(" ", 80).route(Some("example.com")), ProxyRoute::Direct); + assert!(manual(" ", 80).problem().is_some()); + assert!(manual("proxy", 0).problem().is_some()); + assert!(manual("http://proxy", 80).problem().is_some()); + } + + #[test] + fn credentials_are_percent_encoded() { + let p = ProxySettings { + authenticate: true, + login: "dom\\me".into(), + password: "p@ss:w/rd".into(), + ..manual("proxy", 3128) + }; + assert_eq!(p.proxy_url().as_deref(), Some("http://dom%5Cme:p%40ss%3Aw%2Frd@proxy:3128")); + // Not sent without "Proxy authentication". + let off = ProxySettings { authenticate: false, ..p.clone() }; + assert_eq!(off.proxy_url().as_deref(), Some("http://proxy:3128")); + assert!(ProxySettings { login: " ".into(), ..p.clone() }.problem().is_some()); + assert!(!format!("{p:?}").contains("p@ss")); + } + + #[test] + fn no_proxy_for_list_matches_globs_and_loopback() { + let p = ProxySettings { no_proxy: "*.example.com, 192.168.*,Intranet".into(), ..manual("proxy", 3128) }; + assert!(p.bypasses("api.example.com")); + assert!(p.bypasses("a.b.EXAMPLE.com")); + assert!(!p.bypasses("example.com")); + assert!(!p.bypasses("example.com.evil.org")); + assert!(p.bypasses("192.168.1.20")); + assert!(p.bypasses("intranet")); + assert!(!p.bypasses("intranet2")); + assert!(p.bypasses("localhost") && p.bypasses("127.0.0.1") && p.bypasses("[::1]")); + assert_eq!(p.route_for_url("https://user@api.example.com:8443/v1"), ProxyRoute::Direct); + assert_eq!(p.curl_args("http://192.168.0.5/"), ["--noproxy", "*"]); + assert!(matches!(p.route_for_url("https://github.com"), ProxyRoute::Via(_))); + } + + #[test] + fn child_processes_get_the_manual_proxy() { + let p = ProxySettings { + no_proxy: "*.corp, 10.*".into(), + authenticate: true, + login: "me".into(), + password: "pw".into(), + ..manual("proxy", 3128) + }; + let env = p.child_env(); + let get = |k: &str| env.iter().find(|(n, _)| n == k).and_then(|(_, v)| v.clone()).unwrap(); + assert_eq!(get("HTTPS_PROXY"), "http://me:pw@proxy:3128"); + assert_eq!(get("http_proxy"), "http://me:pw@proxy:3128"); + assert_eq!(get("NO_PROXY"), "localhost,127.0.0.1,::1,.corp,10.*"); + } + + #[test] + fn url_hosts() { + assert_eq!(url_host("https://Example.com:443/a?b").as_deref(), Some("example.com")); + assert_eq!(url_host("http://u:p@[::1]:80/").as_deref(), Some("::1")); + assert_eq!(url_host("example.com/x").as_deref(), Some("example.com")); + assert_eq!(url_host("file:///tmp/x"), None); + } +} diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index d40d7aa5..1b8a1413 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -7,6 +7,7 @@ use std::collections::BTreeMap; pub use crate::editor_settings::*; pub use crate::inspection_settings::*; +pub use crate::proxy_settings::{ProxyKind, ProxyMode, ProxySettings}; pub use crate::remote_settings::*; pub use crate::workflow_settings::*; @@ -25,6 +26,8 @@ pub struct Settings { /// keymap's own overrides. `keymap` may name one of these ids. pub custom_keymaps: BTreeMap, pub notifications: NotificationSettings, + /// System Settings › HTTP Proxy (global only). + pub proxy: ProxySettings, /// Interpreters / toolchains per language (Python, Node.js, Rust, Go, TypeScript). pub languages: LanguagesSettings, /// Project Structure: source / test / resource / excluded folders (project-relative). @@ -134,6 +137,7 @@ impl Default for Settings { keymap_overrides: BTreeMap::new(), custom_keymaps: BTreeMap::new(), notifications: NotificationSettings::default(), + proxy: ProxySettings::default(), languages: LanguagesSettings::default(), project_structure: ProjectStructureSettings::default(), vcs: VcsSettings::default(), diff --git a/crates/ide-host/src/environment.rs b/crates/ide-host/src/environment.rs index cfb4bb72..4dabd350 100644 --- a/crates/ide-host/src/environment.rs +++ b/crates/ide-host/src/environment.rs @@ -395,6 +395,8 @@ mod tests { assert_eq!(changed_names(&before, &after), ["B", "C", "D"]); } + // Used by the Unix-only post-start tests. + #[cfg_attr(not(unix), allow(dead_code))] #[derive(Default)] struct Events(Mutex>); impl EventSink for Events { @@ -403,6 +405,7 @@ mod tests { } } + #[cfg_attr(not(unix), allow(dead_code))] fn run(cmd: LifecycleCommand, root: &Path) -> Vec { let events = Arc::new(Events::default()); let sink: SharedSink = events.clone(); @@ -410,6 +413,7 @@ mod tests { events.0.lock().clone() } + #[cfg_attr(not(unix), allow(dead_code))] fn failure(events: &[IdeEvent]) -> Option { events.iter().find_map(|e| match e { IdeEvent::Notify { level: NotifyLevel::Error, body, .. } => body.clone(), diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 6459d2d9..c341b6a9 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -6,6 +6,7 @@ pub mod environment; pub mod ops; mod project; +mod proxy; mod requests; pub mod rpc; pub mod shell_env; @@ -56,6 +57,8 @@ pub struct Host { pub remote: Arc, /// What child processes get beyond this process's environment (Reload Environment, `remoteEnv`). pub environment: environment::Environment, + /// The HTTP proxy's saved password (see `proxy.rs`). + proxy_password: ide_ssh::secrets::SecretFile, pub(crate) sink_for: SinkFactory, owner: fn(&str) -> &str, pub runtime: tokio::runtime::Handle, @@ -71,7 +74,8 @@ impl Host { let global_settings = SettingsStore::load(&config_dir, None); let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json"))); ssh.set_configs(global_settings.effective().ssh.configs); - Self { + let host = Self { + proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")), remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))), ssh, environment: environment::Environment::new(project_environment, data_dir.clone()), @@ -89,7 +93,9 @@ impl Host { runtime, projects: RwLock::default(), requests: Default::default(), - } + }; + host.apply_proxy(); + host } /// The tree-sitter grammars (blocks only if start-up warm-up is still running). diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs index 2663bc5b..02e3202c 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -43,10 +43,10 @@ pub fn current(host: &Host, window: &str) -> Option { /// Effective settings for the window's project, or global settings without one. pub fn settings(host: &Host, window: &str) -> Settings { - match host.project(window) { + host.with_proxy_password(match host.project(window) { Ok(p) => p.settings.effective(), Err(_) => host.global_settings.effective(), - } + }) } pub fn roots(host: &Host, window: &str) -> IdeResult { @@ -64,7 +64,8 @@ pub struct SettingsApplied { /// Sets `key` to `value` (JSON text, e.g. `14` or `"dark"`) for the window's /// project or globally, and applies it to the open projects: file defaults, -/// file types, language servers, index scope and SSH configurations. +/// file types, language servers, index scope, SSH configurations and the HTTP proxy +/// (whose password goes to the saved passwords, not the settings file). pub fn set_settings( host: &Host, window: &str, @@ -77,6 +78,11 @@ pub fn set_settings( let current = host.project(window).ok(); // A LightEdit window has no project layer: its values are global. let project_scope = matches!(scope, SettingsScope::Project) && current.as_ref().is_some_and(|p| !p.light); + let is_proxy = key == "proxy" || key.starts_with("proxy."); + let Some(value) = host.take_proxy_password(key, value)? else { + host.apply_proxy(); + return Ok(SettingsApplied { settings: settings(host, window), project_scope, roots_changed: None }); + }; let settings = match ¤t { Some(p) if project_scope => p.settings.set(scope, key, value)?, Some(p) => { @@ -113,6 +119,9 @@ pub fn set_settings( if key == "ssh" || key.starts_with("ssh.") { host.ssh.set_configs(host.global_settings.effective().ssh.configs); } + if is_proxy { + host.apply_proxy(); + } // A new interpreter / toolchain / server executable restarts the language servers it affects. if ["languages", "tools", "languageServers"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) { for project in affected() { @@ -132,5 +141,5 @@ pub fn set_settings( }); } let roots_changed = current.as_ref().filter(|_| roots_changed && project_scope).map(|p| p.root.clone().into()); - Ok(SettingsApplied { settings, project_scope, roots_changed }) + Ok(SettingsApplied { settings: host.with_proxy_password(settings), project_scope, roots_changed }) } diff --git a/crates/ide-host/src/proxy.rs b/crates/ide-host/src/proxy.rs new file mode 100644 index 00000000..1ce2ed02 --- /dev/null +++ b/crates/ide-host/src/proxy.rs @@ -0,0 +1,50 @@ +//! System Settings › HTTP Proxy on the host: the global settings made current for +//! connections and child processes, the password kept in the saved passwords +//! (owner-only `proxy-password.json`) instead of `settings.toml`. + +use crate::Host; +use ide_core::IdeResult; +use ide_core::settings::Settings; + +const PASSWORD_KEY: &str = "httpProxy"; + +impl Host { + /// `settings` with the saved proxy password filled in, as the Settings dialog edits it. + pub fn with_proxy_password(&self, mut settings: Settings) -> Settings { + // A password typed into settings.toml by hand counts too, unless one was saved. + if let Some(password) = self.proxy_password.get(PASSWORD_KEY) { + settings.proxy.password = password; + } + settings + } + + /// Makes the global proxy settings the proxy of every later connection and child process. + pub fn apply_proxy(&self) { + let settings = self.with_proxy_password(self.global_settings.effective()); + if let Some(problem) = settings.proxy.problem() { + tracing::warn!("HTTP proxy: {problem}"); + } + ide_core::proxy_settings::set_proxy(settings.proxy); + } + + /// Takes the proxy password out of a settings write of `key` and saves it. + /// Returns the value left to write, `None` when nothing is. + pub(crate) fn take_proxy_password( + &self, + key: &str, + mut value: serde_json::Value, + ) -> IdeResult> { + let password = match key { + "proxy.password" => Some(value.take()), + "proxy" => value.as_object_mut().and_then(|o| o.remove("password")), + _ => return Ok(Some(value)), + }; + if let Some(password) = password { + match password.as_str().filter(|p| !p.is_empty()) { + Some(p) => self.proxy_password.set(PASSWORD_KEY, p)?, + None => self.proxy_password.delete(PASSWORD_KEY), + } + } + Ok((key != "proxy.password").then_some(value)) + } +} diff --git a/crates/ide-host/tests/proxy.rs b/crates/ide-host/tests/proxy.rs new file mode 100644 index 00000000..cef7910d --- /dev/null +++ b/crates/ide-host/tests/proxy.rs @@ -0,0 +1,74 @@ +//! System Settings › HTTP Proxy: applied on write, the password kept out of settings.toml. + +use ide_core::NullSink; +use ide_core::proxy_settings::{ProxyMode, ProxyRoute, proxy}; +use ide_host::{Host, HostConfig, ops}; +use ide_workspace::SettingsScope; +use std::sync::Arc; + +fn host(base: &std::path::Path) -> Arc { + Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })) +} + +fn set(host: &Host, key: &str, value: &str) -> ide_core::settings::Settings { + ops::project::set_settings(host, "main", SettingsScope::Global, key, value).unwrap().settings +} + +fn settings_files(base: &std::path::Path) -> String { + let mut text = String::new(); + for entry in walk(&base.join("config")) { + text.push_str(&std::fs::read_to_string(entry).unwrap_or_default()); + } + text +} + +fn walk(dir: &std::path::Path) -> Vec { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let path = entry.path(); + if path.is_dir() { out.extend(walk(&path)) } else { out.push(path) } + } + out +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn manual_proxy_applies_and_the_password_is_saved_apart() { + let tmp = tempfile::tempdir().unwrap(); + let host = host(tmp.path()); + assert_eq!(proxy().mode, ProxyMode::Auto); + + set(&host, "proxy.mode", "\"manual\""); + set(&host, "proxy.host", "\"proxy.corp\""); + set(&host, "proxy.port", "3128"); + set(&host, "proxy.authenticate", "true"); + set(&host, "proxy.login", "\"me\""); + let settings = set(&host, "proxy.password", "\"s3cret\""); + assert_eq!(settings.proxy.password, "s3cret", "the dialog sees the saved password"); + assert_eq!( + proxy().route_for_url("https://example.com/"), + ProxyRoute::Via("http://me:s3cret@proxy.corp:3128".into()) + ); + + assert!(!settings_files(tmp.path()).contains("s3cret"), "no password in settings.toml"); + let saved = std::fs::read_to_string(tmp.path().join("data/proxy-password.json")).unwrap(); + assert!(saved.contains("s3cret")); + assert_eq!(ops::project::settings(&host, "main").proxy.password, "s3cret"); + + // A whole-table write keeps the password out of the file too; an empty one forgets it. + let settings = set(&host, "proxy", r#"{"mode":"none","host":"proxy.corp","password":""}"#); + assert_eq!(settings.proxy.password, ""); + assert!(!settings_files(tmp.path()).contains("password")); + assert_eq!(proxy().route_for_url("https://example.com/"), ProxyRoute::Direct); + + set(&host, "proxy", r#"{"mode":"auto"}"#); + assert_eq!(proxy().mode, ProxyMode::Auto); +} diff --git a/crates/ide-http/src/client.rs b/crates/ide-http/src/client.rs new file mode 100644 index 00000000..0623200e --- /dev/null +++ b/crates/ide-http/src/client.rs @@ -0,0 +1,187 @@ +//! The one place the backend builds an HTTP client: TLS with the platform's +//! certificates, timeouts, and the proxy of System Settings › HTTP Proxy. + +use ide_core::proxy_settings::{ProxyMode, ProxyRoute, ProxySettings}; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use parking_lot::Mutex; +use std::sync::Arc; +use std::time::Duration; + +/// `builder` routed as `proxy` says: direct, the environment's proxy (reqwest's +/// default), or the manual proxy except for the "No proxy for" hosts. +pub fn with_proxy(builder: reqwest::ClientBuilder, proxy: &ProxySettings) -> reqwest::ClientBuilder { + match proxy.mode { + ProxyMode::Auto => builder, + ProxyMode::None => builder.no_proxy(), + ProxyMode::Manual => { + let proxy = proxy.clone(); + // A custom proxy also turns off the environment's. + builder.proxy(reqwest::Proxy::custom(move |url| match proxy.route(url.host_str()) { + ProxyRoute::Via(via) => Some(via), + ProxyRoute::Direct | ProxyRoute::System => None, + })) + } + } +} + +/// A client for `proxy`. +pub fn build(proxy: &ProxySettings) -> IdeResult { + use rustls_platform_verifier::BuilderVerifierExt; + let fail = |e: String| IdeError::internal(format!("HTTP client: {e}")); + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let tls = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|e| fail(e.to_string()))? + .with_platform_verifier() + .map_err(|e| fail(e.to_string()))? + .with_no_client_auth(); + let builder = reqwest::Client::builder() + .tls_backend_preconfigured(tls) + .connect_timeout(Duration::from_secs(30)) + .user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION"))); + with_proxy(builder, proxy).build().map_err(|e| fail(e.to_string())) +} + +/// The client for the current proxy settings, rebuilt when they change. +pub fn shared() -> IdeResult { + static CLIENT: Mutex, reqwest::Client)>> = Mutex::new(None); + let proxy = ide_core::proxy_settings::proxy(); + let mut cached = CLIENT.lock(); + if let Some((settings, client)) = cached.as_ref() + && **settings == *proxy + { + return Ok(client.clone()); + } + let client = build(&proxy)?; + *cached = Some((proxy, client.clone())); + Ok(client) +} + +/// HTTP Proxy › Check connection: GETs `url` through `proxy` (the settings as +/// edited, not yet applied). Any response but `407` counts as connected. +pub async fn check_connection(proxy: &ProxySettings, url: &str, cancel: &CancellationToken) -> IdeResult { + if let Some(problem) = proxy.problem() { + return Err(IdeError::invalid(problem)); + } + let url = url.trim(); + let url = if url.contains("://") { url.to_owned() } else { format!("http://{url}") }; + let client = build(proxy)?; + let request = client.get(&url).timeout(Duration::from_secs(30)); + let response = tokio::select! { + r = request.send() => r.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Io, describe(&e)))?, + () = cancel.cancelled() => return Err(IdeError::cancelled()), + }; + let status = response.status(); + if status == reqwest::StatusCode::PROXY_AUTHENTICATION_REQUIRED { + return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "Proxy authentication required (407)")); + } + Ok(format!("{} {}", status.as_u16(), status.canonical_reason().unwrap_or("")).trim_end().to_owned()) +} + +/// reqwest errors with their causes ("error sending request: … connection refused"). +pub(crate) fn describe(e: &reqwest::Error) -> String { + use std::error::Error; + let mut msg = e.to_string(); + let mut source = e.source(); + while let Some(s) = source { + let s_msg = s.to_string(); + if !msg.contains(&s_msg) { + msg.push_str(": "); + msg.push_str(&s_msg); + } + source = s.source(); + } + msg +} + +#[cfg(test)] +mod tests { + use super::*; + use ide_core::proxy_settings::ProxyKind; + use std::io::{BufRead, BufReader, Write}; + use std::net::TcpListener; + + /// A one-shot HTTP server on loopback: answers `200` and hands back the request head. + fn server() -> (u16, std::thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut head = String::new(); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap() == 0 || line == "\r\n" { + break; + } + head.push_str(&line); + } + let mut stream = stream; + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok").unwrap(); + head + }); + (port, handle) + } + + fn manual(port: u16) -> ProxySettings { + ProxySettings { mode: ProxyMode::Manual, host: "127.0.0.1".into(), port, ..Default::default() } + } + + async fn get(proxy: &ProxySettings, url: &str) -> IdeResult { + check_connection(proxy, url, &CancellationToken::new()).await + } + + #[tokio::test] + async fn builds_for_every_mode() { + for mode in [ProxyMode::None, ProxyMode::Auto, ProxyMode::Manual] { + for kind in [ProxyKind::Http, ProxyKind::Socks] { + let p = ProxySettings { mode, kind, host: "proxy".into(), port: 3128, ..Default::default() }; + assert!(build(&p).is_ok(), "{mode:?} {kind:?}"); + } + } + } + + #[tokio::test] + async fn manual_proxy_receives_the_request_with_credentials() { + let (port, proxy_server) = server(); + let proxy = ProxySettings { authenticate: true, login: "me".into(), password: "s3cret".into(), ..manual(port) }; + assert_eq!(get(&proxy, "http://example.invalid/ping").await.unwrap(), "200 OK"); + let head = proxy_server.join().unwrap(); + assert!(head.starts_with("GET http://example.invalid/ping HTTP/1.1"), "{head}"); + // base64("me:s3cret") + assert!(head.to_ascii_lowercase().contains("proxy-authorization: basic bwu6cznjcmv0"), "{head}"); + } + + #[tokio::test] + async fn manual_proxy_without_authentication_sends_no_credentials() { + let (port, proxy_server) = server(); + let proxy = ProxySettings { login: "me".into(), password: "x".into(), ..manual(port) }; + get(&proxy, "http://example.invalid/").await.unwrap(); + assert!(!proxy_server.join().unwrap().to_ascii_lowercase().contains("proxy-authorization")); + } + + #[tokio::test] + async fn no_proxy_hosts_and_no_proxy_mode_connect_directly() { + // Nothing listens on the proxy port: a request through it would fail. + let dead = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + let (port, target) = server(); + let listed = ProxySettings { no_proxy: "example.com, 127.0.*".into(), ..manual(dead) }; + assert!(listed.bypasses("127.0.0.1")); + get(&listed, &format!("http://127.0.0.1:{port}/direct")).await.unwrap(); + assert!(target.join().unwrap().starts_with("GET /direct HTTP/1.1")); + + let (port, target) = server(); + let none = ProxySettings { mode: ProxyMode::None, ..manual(dead) }; + get(&none, &format!("http://127.0.0.1:{port}/none")).await.unwrap(); + assert!(target.join().unwrap().starts_with("GET /none HTTP/1.1")); + } + + #[tokio::test] + async fn check_reports_errors() { + let dead = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + let e = get(&manual(dead), "http://example.invalid/").await.unwrap_err(); + assert!(!e.message.is_empty()); + let e = get(&manual(0), "http://example.invalid/").await.unwrap_err(); + assert!(e.message.contains("port"), "{}", e.message); + } +} diff --git a/crates/ide-http/src/exec.rs b/crates/ide-http/src/exec.rs index 4a0c1453..47802fa4 100644 --- a/crates/ide-http/src/exec.rs +++ b/crates/ide-http/src/exec.rs @@ -1,5 +1,6 @@ //! Preparing (substitution, body includes) and executing requests. +use crate::client::describe; use crate::env::{Environments, Resolver}; use crate::parse::{BodyPart, RawRequest}; use ide_core::{CancellationToken, IdeError, IdeResult}; @@ -7,7 +8,6 @@ use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::BTreeSet; use std::path::Path; -use std::sync::{Arc, OnceLock}; use std::time::Instant; /// Bodies above this are cut (the full size is still reported). @@ -98,29 +98,6 @@ pub fn prepare( }) } -fn client() -> IdeResult { - static CLIENT: OnceLock> = OnceLock::new(); - CLIENT - .get_or_init(|| { - use rustls_platform_verifier::BuilderVerifierExt; - let provider = Arc::new(rustls::crypto::ring::default_provider()); - let tls = rustls::ClientConfig::builder_with_provider(provider) - .with_safe_default_protocol_versions() - .map_err(|e| e.to_string())? - .with_platform_verifier() - .map_err(|e| e.to_string())? - .with_no_client_auth(); - reqwest::Client::builder() - .tls_backend_preconfigured(tls) - .connect_timeout(std::time::Duration::from_secs(30)) - .user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION"))) - .build() - .map_err(|e| e.to_string()) - }) - .clone() - .map_err(|e| IdeError::internal(format!("HTTP client: {e}"))) -} - fn looks_textual(content_type: Option<&str>, bytes: &[u8]) -> bool { if let Some(ct) = content_type { let ct = ct.to_ascii_lowercase(); @@ -148,7 +125,7 @@ fn looks_textual(content_type: Option<&str>, bytes: &[u8]) -> bool { /// Sends `req`; cancelled when `cancel` fires. pub async fn execute(req: &PreparedRequest, cancel: &CancellationToken) -> IdeResult { - let client = client()?; + let client = crate::client::shared()?; let method = reqwest::Method::from_bytes(req.method.as_bytes()) .map_err(|_| IdeError::invalid(format!("invalid method {}", req.method)))?; let mut builder = client.request(method, &req.url); @@ -205,22 +182,6 @@ pub async fn execute(req: &PreparedRequest, cancel: &CancellationToken) -> IdeRe }) } -/// reqwest errors with their causes ("error sending request: … connection refused"). -fn describe(e: &reqwest::Error) -> String { - use std::error::Error; - let mut msg = e.to_string(); - let mut source = e.source(); - while let Some(s) = source { - let s_msg = s.to_string(); - if !msg.contains(&s_msg) { - msg.push_str(": "); - msg.push_str(&s_msg); - } - source = s.source(); - } - msg -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ide-http/src/lib.rs b/crates/ide-http/src/lib.rs index cc09d42a..27b5f3f0 100644 --- a/crates/ide-http/src/lib.rs +++ b/crates/ide-http/src/lib.rs @@ -2,8 +2,10 @@ //! //! [`parse`] splits a file into requests, [`env`] resolves `{{variables}}` from //! `http-client(.private).env.json`, [`exec`] sends a request (cancellable, -//! timed) and [`history`] keeps the responses. +//! timed) and [`history`] keeps the responses. [`client`] builds every HTTP client +//! of the backend, with the proxy of System Settings › HTTP Proxy. +pub mod client; pub mod env; pub mod exec; pub mod history; diff --git a/crates/ide-ssh/src/secrets.rs b/crates/ide-ssh/src/secrets.rs index 5612ec9e..0e869dfa 100644 --- a/crates/ide-ssh/src/secrets.rs +++ b/crates/ide-ssh/src/secrets.rs @@ -1,6 +1,6 @@ -//! Saved SSH passwords / key passphrases of SSH configurations, in the app's -//! data directory rather than the OS credential store (no system prompts). -//! One JSON object, configuration id → secret; owner-only on Unix. +//! Saved passwords (SSH passwords / key passphrases by configuration id, the HTTP +//! proxy's), in the app's data directory rather than the OS credential store (no +//! system prompts). One JSON object, key → secret; owner-only on Unix. use ide_core::{IdeError, IdeResult}; use parking_lot::Mutex; @@ -35,14 +35,14 @@ impl SecretFile { if all.remove(key).is_some() && let Err(e) = self.write(&all) { - tracing::warn!("saved SSH passwords: {}", e.message); + tracing::warn!("{}", e.message); } } fn read(&self) -> BTreeMap { match std::fs::read(&self.path) { Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| { - tracing::warn!("saved SSH passwords: {}: {e}", self.path.display()); + tracing::warn!("saved passwords: {}: {e}", self.path.display()); BTreeMap::new() }), Err(_) => BTreeMap::new(), @@ -50,7 +50,7 @@ impl SecretFile { } fn write(&self, all: &BTreeMap) -> IdeResult<()> { - let err = |e: std::io::Error| IdeError::unavailable(format!("saved SSH passwords: {e}")); + let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {}: {e}", self.path.display())); if let Some(dir) = self.path.parent() { std::fs::create_dir_all(dir).map_err(err)?; } diff --git a/crates/ide-tools/src/process.rs b/crates/ide-tools/src/process.rs index 56a51701..159a7d60 100644 --- a/crates/ide-tools/src/process.rs +++ b/crates/ide-tools/src/process.rs @@ -198,6 +198,8 @@ impl Http { let mut cmd = Command::new(&self.curl); own_group(ide_core::process::prepare(&mut cmd)); cmd.args(["-fsSL", "--retry", "2", "--connect-timeout", "20", "-A", "openidea"]); + // System Settings › HTTP Proxy (a manual proxy comes in through `prepare`'s environment). + cmd.args(ide_core::proxy_settings::proxy().curl_args(url)); if self.https_only { cmd.args(["--proto", "=https", "--proto-redir", "=https"]); } diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 210abfd7..b158bda0 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -128,7 +128,7 @@ pub fn settings_get(window: Window, state: State<'_, AppState>) -> Settings { #[tauri::command] #[specta::specta] pub fn settings_get_global(state: State<'_, AppState>) -> Settings { - state.global_settings.effective() + state.host.with_proxy_password(state.global_settings.effective()) } /// `value` is JSON text, e.g. `14` or `"dark"`. Every window (for a project @@ -196,11 +196,9 @@ pub async fn settings_import(window: Window, state: State<'_, AppState>, path: P tauri::async_runtime::spawn(async move { lsp.reconfigure().await }); } crate::commands::ssh::apply_ssh_settings(&state); + state.host.apply_proxy(); state.sink.emit(ide_core::IdeEvent::SettingsChanged { key: String::new() }); - let settings = match state.project(window.label()) { - Ok(p) => p.settings.effective(), - Err(_) => state.global_settings.effective(), - }; + let settings = ide_host::ops::project::settings(&state.host, window.label()); Ok(SettingsImport { settings, storage }) } diff --git a/src-tauri/src/commands/http.rs b/src-tauri/src/commands/http.rs index 8428a799..9424ecf0 100644 --- a/src-tauri/src/commands/http.rs +++ b/src-tauri/src/commands/http.rs @@ -76,3 +76,11 @@ pub fn http_history_get(window: Window, state: State<'_, AppState>, id: String) pub fn http_history_clear(window: Window, state: State<'_, AppState>) -> IdeResult<()> { state.project(window.label())?.http_history.clear() } + +/// System Settings › HTTP Proxy › Check connection: GETs `url` through `proxy` as +/// edited (not yet applied); the response status, or why there was none. +#[tauri::command] +#[specta::specta] +pub async fn proxy_check_connection(proxy: ide_core::settings::ProxySettings, url: String) -> IdeResult { + ide_http::client::check_connection(&proxy, &url, &ide_core::CancellationToken::new()).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 9f4ea554..0db71f35 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -394,6 +394,7 @@ pub fn builder() -> tauri_specta::Builder { http::http_history, http::http_history_get, http::http_history_clear, + http::proxy_check_connection, db::db_sources, db::db_save_sources, db::db_set_password, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 2806e366..3d675207 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -295,7 +295,8 @@ fn updater(app: &AppHandle) -> IdeResult> return Ok(None); } let env = update_endpoint_env(); - let mut builder = app.updater_builder(); + let proxy = ide_core::proxy_settings::proxy(); + let mut builder = app.updater_builder().configure_client(move |b| ide_http::client::with_proxy(b, &proxy)); if let Some(url) = env { let url = url.parse().map_err(|e| IdeError::invalid(format!("OPENIDEA_UPDATE_ENDPOINT: {e}")))?; builder = builder.endpoints(vec![url]).map_err(|e| IdeError::invalid(e.to_string()))?; diff --git a/src/bindings.ts b/src/bindings.ts index b827d537..70bcd3d2 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -850,6 +850,11 @@ export const commands = { httpHistory: () => typedError(__TAURI_INVOKE("http_history")), httpHistoryGet: (id: string) => typedError(__TAURI_INVOKE("http_history_get", { id })), httpHistoryClear: () => typedError(__TAURI_INVOKE("http_history_clear")), + /** + * System Settings › HTTP Proxy › Check connection: GETs `url` through `proxy` as + * edited (not yet applied); the response status, or why there was none. + */ + proxyCheckConnection: (proxy: ProxySettings, url: string) => typedError(__TAURI_INVOKE("proxy_check_connection", { proxy, url })), dbSources: () => typedError(__TAURI_INVOKE("db_sources")), dbSaveSources: (sources: DataSource[]) => typedError(__TAURI_INVOKE("db_save_sources", { sources })), /** @@ -4511,6 +4516,30 @@ export type ProjectStructureSettings = { excludeFiles?: string, }; +export type ProxyKind = "http" | "socks"; + +export type ProxyMode = +/** Every connection goes direct. */ +"none" | +/** The system's proxy: `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` of the environment. */ +"auto" | +/** The proxy configured here. */ +"manual"; + +export type ProxySettings = { + mode?: ProxyMode, + kind?: ProxyKind, + host?: string, + port?: number, + /** Hosts reached directly, comma-separated; `*` matches any characters (`*.example.com`, `192.168.*`). */ + noProxy?: string, + /** Send `login` / `password` to the proxy. */ + authenticate?: boolean, + login?: string, + /** Kept in the saved passwords rather than `settings.toml`; filled in when settings are read. */ + password?: string, +}; + export type PruneTarget = "containers" | /** Dangling images only. */ "images" | @@ -5139,6 +5168,8 @@ export type Settings_Deserialize = { */ customKeymaps?: { [key in string]: CustomKeymap }, notifications?: NotificationSettings, + /** System Settings › HTTP Proxy (global only). */ + proxy?: ProxySettings, /** Interpreters / toolchains per language (Python, Node.js, Rust, Go, TypeScript). */ languages?: LanguagesSettings, /** Project Structure: source / test / resource / excluded folders (project-relative). */ @@ -5192,6 +5223,8 @@ export type Settings_Serialize = { */ customKeymaps: { [key in string]: CustomKeymap }, notifications: NotificationSettings, + /** System Settings › HTTP Proxy (global only). */ + proxy: ProxySettings, /** Interpreters / toolchains per language (Python, Node.js, Rust, Go, TypeScript). */ languages: LanguagesSettings, /** Project Structure: source / test / resource / excluded folders (project-relative). */ diff --git a/src/features/settings/model/schema.ts b/src/features/settings/model/schema.ts index af2904a1..9a5bc568 100644 --- a/src/features/settings/model/schema.ts +++ b/src/features/settings/model/schema.ts @@ -18,6 +18,7 @@ import { import { EDITOR_EXTRA_DEFAULTS, validateEditorExtras, type EditorExtras } from "@/platform/editorSettings"; import { DEFAULT_WORKFLOW, validateWorkflow, type WorkflowSettings } from "@/platform/workflowSettings"; import { DEFAULT_INSPECTIONS, validateInspections, type InspectionSettingsValid } from "@/platform/inspections"; +import { DEFAULT_PROXY, validateProxy, type ProxySettingsValid } from "@/platform/proxySettings"; import { DEFAULT_APPEARANCE_EXTRAS, DEFAULT_GENERAL_EXTRAS, @@ -77,6 +78,8 @@ export interface ValidSettings extends WorkflowSettings { keymapOverrides: Record; customKeymaps: Record; notifications: NotificationsValid; + /** System Settings › HTTP Proxy (global). */ + proxy: ProxySettingsValid; languages: LanguagesValid; projectStructure: ProjectStructureValid; inspections: InspectionSettingsValid; @@ -143,6 +146,7 @@ export const DEFAULT_SETTINGS: ValidSettings = { keymapOverrides: {}, customKeymaps: {}, notifications: DEFAULT_NOTIFICATIONS, + proxy: DEFAULT_PROXY, languages: DEFAULT_LANGUAGES, projectStructure: DEFAULT_PROJECT_STRUCTURE, inspections: DEFAULT_INSPECTIONS, @@ -245,6 +249,7 @@ export function validateSettings(raw: Settings | unknown): ValidSettings { keymapOverrides: overrides, customKeymaps, notifications: validateNotifications(root.notifications), + proxy: validateProxy(root.proxy), languages: validateLanguages(root.languages), projectStructure: validateProjectStructure(root.projectStructure), inspections: validateInspections(root.inspections), diff --git a/src/features/settings/pages/HttpProxy.module.css b/src/features/settings/pages/HttpProxy.module.css new file mode 100644 index 00000000..1145e8e8 --- /dev/null +++ b/src/features/settings/pages/HttpProxy.module.css @@ -0,0 +1,88 @@ +.page { + display: flex; + flex-direction: column; + gap: 4px; +} +.modes { + display: flex; + flex-direction: column; +} +.radio { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 28px; +} +.radio input { + margin: 0; + accent-color: var(--accent); +} +.disabled { + color: var(--fg-subtle); +} +.hint { + padding-left: 20px; + color: var(--fg-subtle); +} + +/* Manual configuration, indented under its radio button. */ +.manual { + display: flex; + flex-direction: column; + padding-left: 20px; +} +.kinds { + display: flex; + align-items: center; + gap: 16px; +} +.hostRow { + display: flex; + align-items: flex-start; + gap: 24px; +} +.host { + width: 220px; +} +.port { + width: 64px; +} +.wide { + width: 360px; +} +.toggle { + display: flex; + align-items: center; + min-height: 28px; +} +.auth { + display: flex; + flex-direction: column; + padding-left: 20px; +} +.error { + color: var(--danger); +} +.ok { + color: var(--success, var(--fg-muted)); +} + +.check { + margin-top: 12px; +} +.popup { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 14px; + max-width: 480px; +} +.urlRow { + display: flex; + align-items: center; + gap: 8px; +} +.result { + min-height: 1lh; + overflow-wrap: anywhere; +} diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index 23028de3..c9e66b02 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -1,6 +1,6 @@ /** * Appearance & Behavior pages, laid out like IntelliJ IDEA / PyCharm: - * Appearance, System Settings (+ Language and Region, Date Formats) and + * Appearance, System Settings (+ Language and Region, Date Formats, HTTP Proxy) and * Notifications. Every option is wired (see `appearanceRuntime`, workbench * theme / tool windows / notifications, app title / windows, `platform/dateFormat`). */ @@ -26,6 +26,7 @@ import { } from "../model/behaviorSchema"; import { ACCENT_PRESETS, LIMITS, type ThemeId } from "../model/schema"; import type { PageSpec } from "./spec"; +import { HTTP_PROXY_PAGE } from "./httpProxy"; import styles from "./Behavior.module.css"; import { tx, useT } from "@/platform/i18n"; @@ -673,6 +674,7 @@ export const BEHAVIOR_PAGES: PageSpec[] = [ }, ], }, + HTTP_PROXY_PAGE, { id: "notifications", title: "Notifications", diff --git a/src/features/settings/pages/httpProxy.tsx b/src/features/settings/pages/httpProxy.tsx new file mode 100644 index 00000000..c775c542 --- /dev/null +++ b/src/features/settings/pages/httpProxy.tsx @@ -0,0 +1,297 @@ +/** + * Appearance & Behavior › System Settings › HTTP Proxy: how the IDE's own + * connections (HTTP Client, tool / JDK / agent downloads, update checks) reach + * the network. A manual proxy also reaches child processes as `HTTP_PROXY`. + */ +import { useId, useRef, useState } from "react"; +import clsx from "clsx"; +import { call, commands, isTauri } from "@/platform/ipc"; +import { tx, useTx } from "@/platform/i18n"; +import { + PROXY_PORT_LIMITS, + proxyProblem, + withProxyKind, + type ProxyKindValid, + type ProxyModeValid, + type ProxySettingsValid, +} from "@/platform/proxySettings"; +import { Button, Checkbox, Input, Popup, T } from "@/ui"; +import { NumberInput, Row, SearchLabel } from "../components/Form"; +import { usePageContext, useSetting } from "../components/context"; +import { stage } from "../model/staging"; +import type { PageSpec } from "./spec"; +import styles from "./HttpProxy.module.css"; + +const MODES: Array<{ value: ProxyModeValid; label: string; hint?: string }> = [ + { value: "none", label: "No proxy" }, + { + value: "auto", + label: "Auto-detect proxy settings", + hint: "Uses the system proxy: the HTTP_PROXY, HTTPS_PROXY and NO_PROXY environment variables.", + }, + { value: "manual", label: "Manual proxy configuration" }, +]; + +const KINDS: Array<{ value: ProxyKindValid; label: string }> = [ + { value: "http", label: "HTTP" }, + { value: "socks", label: "SOCKS" }, +]; + +/** The staged proxy settings and a setter per field (each written as its own key). */ +function useProxy(): [ProxySettingsValid, (patch: Partial) => void] { + const [proxy] = useSetting("proxy", "global"); + const { pageId } = usePageContext(); + const update = (patch: Partial) => { + for (const [field, value] of Object.entries(patch)) stage(pageId, "global", `proxy.${field}`, value); + }; + return [proxy, update]; +} + +function Radio({ + name, + checked, + onSelect, + label, + disabled, +}: { + name: string; + checked: boolean; + onSelect: () => void; + label: string; + disabled?: boolean; +}) { + return ( + + ); +} + +function ProxyOptions() { + const [proxy, update] = useProxy(); + const name = useId(); + const kindName = useId(); + const ids = { + host: useId(), + port: useId(), + noProxy: useId(), + login: useId(), + password: useId(), + }; + const manual = proxy.mode === "manual"; + const auth = manual && proxy.authenticate; + const problem = proxyProblem(proxy); + return ( +
+
+ {MODES.map((m) => ( +
+ update({ mode: m.value })} + label={m.label} + /> + {m.hint && ( +
+ {m.hint} +
+ )} +
+ ))} +
+
+
+ {KINDS.map((k) => ( + update(withProxyKind(proxy, k.value))} + label={k.label} + /> + ))} +
+
+ + update({ host: e.target.value })} + /> + + + {manual ? ( + update({ port })} + {...PROXY_PORT_LIMITS} + integer + aria-label="Port number" + /> + ) : ( + + )} + +
+ + update({ noProxy: e.target.value })} + /> + +
+ update({ authenticate })} + label={} + /> +
+
+ + update({ login: e.target.value })} + /> + + + update({ password: e.target.value })} + /> + +
+ {problem && ( +
+ {problem} +
+ )} +
+ +
+ ); +} + +type CheckState = { status: "idle" | "running" } | { status: "ok" | "failed"; text: string }; + +/** Check connection…: asks for a URL and GETs it with the settings as edited. */ +function CheckConnection({ proxy }: { proxy: ProxySettingsValid }) { + const t = useTx(); + const anchor = useRef(null); + const urlId = useId(); + const [open, setOpen] = useState(false); + const [url, setUrl] = useState("http://"); + const [state, setState] = useState({ status: "idle" }); + const check = async () => { + setState({ status: "running" }); + try { + const status = await call(commands.proxyCheckConnection(proxy, url)); + setState({ status: "ok", text: t`Connection successful: ${status}` }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + setState({ + status: "failed", + text: t`Problem with connection: ${message}`, + }); + } + }; + const running = state.status === "running"; + return ( +
+ + setOpen(false)} + anchor={anchor} + placement="bottom-start" + offset={4} + aria-label="Check connection" + > +
{ + e.preventDefault(); + if (!running && url.trim()) void check(); + }} + > + + + setUrl(e.target.value)} /> + + + + {state.status === "ok" || state.status === "failed" ? state.text : running ? t("Connecting…") : ""} + +
+
+
+ ); +} + +export const HTTP_PROXY_PAGE: PageSpec = { + id: "general.httpProxy", + title: "HTTP Proxy", + order: 2, + keywords: ["proxy", "network", "socks", "http_proxy", "no_proxy", "connection"], + sections: [ + { + options: [ + { + kind: "custom", + label: "Manual proxy configuration", + keywords: [ + "No proxy", + "Auto-detect proxy settings", + "Host name", + "Port number", + "No proxy for", + "Proxy authentication", + "Login", + "Password", + "Check connection…", + ], + component: ProxyOptions, + }, + ], + }, + ], +}; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 2f3dfe2b..c722229d 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4685,5 +4685,28 @@ "Code insight needs the index; it can't be stopped": "代码洞察依赖索引,无法停止", "Waiting for other tasks": "正在等待其他任务", "This task can't be cancelled": "此任务无法取消", - "Cancelled": "已取消" + "Cancelled": "已取消", + "No proxy": "不使用代理", + "Auto-detect proxy settings": "自动检测代理设置", + "Uses the system proxy: the HTTP_PROXY, HTTPS_PROXY and NO_PROXY environment variables.": "使用系统代理:HTTP_PROXY、HTTPS_PROXY 和 NO_PROXY 环境变量。", + "Manual proxy configuration": "手动配置代理", + "Proxy": "代理", + "Proxy type": "代理类型", + "Host name": "主机名", + "Port number": "端口号", + "No proxy for": "不对以下主机使用代理", + "Comma-separated host names; * matches any characters, e.g. *.example.com, 192.168.*": "以逗号分隔的主机名;* 匹配任意字符,例如 *.example.com, 192.168.*", + "Proxy authentication": "代理身份验证", + "Login": "登录名", + "Connection successful: {status}": "连接成功:{status}", + "Problem with connection: {message}": "连接出现问题:{message}", + "Check connection…": "检查连接…", + "Check connection": "检查连接", + "Enter any URL to check connection to:": "输入要检查连接的任意 URL:", + "Check": "检查", + "HTTP Proxy": "HTTP 代理", + "Proxy host is empty": "代理主机为空", + "Invalid proxy host": "代理主机无效", + "Proxy port must be between 1 and 65535": "代理端口必须介于 1 和 65535 之间", + "Proxy login is empty": "代理登录名为空" } diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index f51e8000..17d0d1c2 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -155,6 +155,7 @@ const UNTRANSLATED = new Set([ "GOPATH", "GOROOT", "HEAD", + "HTTP", "JDK", "JDK 1.4.x", "JDK 5 - 8", @@ -162,6 +163,7 @@ const UNTRANSLATED = new Set([ "NULL", "ORDER BY", "SDK", + "SOCKS", "SSH/SSL", "SSL", "TODO", diff --git a/src/platform/proxySettings.test.ts b/src/platform/proxySettings.test.ts new file mode 100644 index 00000000..2f838b5d --- /dev/null +++ b/src/platform/proxySettings.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { validateSettings } from "@/features/settings/model/schema"; +import { DEFAULT_PROXY, proxyProblem, validateProxy, withProxyKind, type ProxySettingsValid } from "./proxySettings"; + +const manual = (patch: Partial = {}): ProxySettingsValid => ({ + ...DEFAULT_PROXY, + mode: "manual", + host: "proxy.corp", + port: 3128, + ...patch, +}); + +describe("validateProxy", () => { + it("defaults to auto-detect", () => { + expect(validateProxy(undefined)).toEqual(DEFAULT_PROXY); + expect(DEFAULT_PROXY.mode).toBe("auto"); + expect(validateSettings({}).proxy).toEqual(DEFAULT_PROXY); + }); + + it("keeps valid values", () => { + const raw = { + mode: "manual", + kind: "socks", + host: "10.0.0.1", + port: 1081, + noProxy: "*.example.com, 192.168.*", + authenticate: true, + login: "me", + password: "pw", + }; + expect(validateProxy(raw)).toEqual(raw); + expect(validateProxy({ mode: "none" }).mode).toBe("none"); + }); + + it("falls back on malformed values", () => { + const p = validateProxy({ mode: "pac", kind: "ftp", host: 7, port: 70000, authenticate: "yes", login: null }); + expect(p).toEqual(DEFAULT_PROXY); + expect(validateProxy({ kind: "socks", port: 0 }).port).toBe(1080); + expect(validateProxy({ port: 80.5 }).port).toBe(80); + expect(validateProxy([]).mode).toBe("auto"); + }); +}); + +describe("withProxyKind", () => { + it("moves a default port along and keeps a custom one", () => { + expect(withProxyKind(manual({ port: 80 }), "socks")).toMatchObject({ kind: "socks", port: 1080 }); + expect(withProxyKind(manual({ kind: "socks", port: 1080 }), "http").port).toBe(80); + expect(withProxyKind(manual({ port: 3128 }), "socks").port).toBe(3128); + }); +}); + +describe("proxyProblem", () => { + it("checks only a manual configuration", () => { + expect(proxyProblem({ ...DEFAULT_PROXY, host: "" })).toBeNull(); + expect(proxyProblem({ ...DEFAULT_PROXY, mode: "none" })).toBeNull(); + expect(proxyProblem(manual())).toBeNull(); + }); + + it("reports a missing or invalid host, port and login", () => { + expect(proxyProblem(manual({ host: " " }))).toBe("Proxy host is empty"); + expect(proxyProblem(manual({ host: "http://proxy" }))).toBe("Invalid proxy host"); + expect(proxyProblem(manual({ host: "user@proxy" }))).toBe("Invalid proxy host"); + expect(proxyProblem(manual({ port: 0 }))).toBe("Proxy port must be between 1 and 65535"); + expect(proxyProblem(manual({ authenticate: true, login: "" }))).toBe("Proxy login is empty"); + expect(proxyProblem(manual({ authenticate: true, login: "me", password: "" }))).toBeNull(); + expect(proxyProblem(manual({ authenticate: false, login: "" }))).toBeNull(); + }); +}); diff --git a/src/platform/proxySettings.ts b/src/platform/proxySettings.ts new file mode 100644 index 00000000..9bb3ed3f --- /dev/null +++ b/src/platform/proxySettings.ts @@ -0,0 +1,82 @@ +/** + * System Settings › HTTP Proxy as the UI consumes it (mirrors + * `ide-core::proxy_settings` defaults). Normalisation never throws. + */ +export type ProxyModeValid = "none" | "auto" | "manual"; +export type ProxyKindValid = "http" | "socks"; + +export interface ProxySettingsValid { + mode: ProxyModeValid; + kind: ProxyKindValid; + host: string; + port: number; + /** Comma-separated host globs reached directly (`*.example.com, 192.168.*`). */ + noProxy: string; + authenticate: boolean; + login: string; + /** Kept in the saved passwords by the backend, not in settings.toml. */ + password: string; +} + +export const PROXY_DEFAULT_PORT: Record = { + http: 80, + socks: 1080, +}; +export const PROXY_PORT_LIMITS = { min: 1, max: 65535 } as const; + +export const DEFAULT_PROXY: ProxySettingsValid = { + mode: "auto", + kind: "http", + host: "", + port: PROXY_DEFAULT_PORT.http, + noProxy: "", + authenticate: false, + login: "", + password: "", +}; + +type Obj = Record; +const obj = (v: unknown): Obj => (v && typeof v === "object" && !Array.isArray(v) ? (v as Obj) : {}); +const str = (v: unknown) => (typeof v === "string" ? v : ""); + +export function validateProxy(raw: unknown): ProxySettingsValid { + const p = obj(raw); + const mode: ProxyModeValid = p.mode === "none" || p.mode === "manual" ? p.mode : "auto"; + const kind: ProxyKindValid = p.kind === "socks" ? "socks" : "http"; + const port = + typeof p.port === "number" && + Number.isInteger(p.port) && + p.port >= PROXY_PORT_LIMITS.min && + p.port <= PROXY_PORT_LIMITS.max + ? p.port + : PROXY_DEFAULT_PORT[kind]; + return { + mode, + kind, + host: str(p.host), + port, + noProxy: str(p.noProxy), + authenticate: p.authenticate === true, + login: str(p.login), + password: str(p.password), + }; +} + +/** Switching HTTP ⇄ SOCKS moves a default port along. */ +export function withProxyKind(p: ProxySettingsValid, kind: ProxyKindValid): ProxySettingsValid { + const port = p.port === PROXY_DEFAULT_PORT[p.kind] ? PROXY_DEFAULT_PORT[kind] : p.port; + return { ...p, kind, port }; +} + +/** What keeps a manual configuration from working (as the backend checks it), or `null`. */ +export function proxyProblem(p: ProxySettingsValid): string | null { + if (p.mode !== "manual") return null; + const host = p.host.trim(); + if (!host) return "Proxy host is empty"; + if (/[/@\s]/.test(host)) return "Invalid proxy host"; + if (p.port < PROXY_PORT_LIMITS.min || p.port > PROXY_PORT_LIMITS.max) { + return "Proxy port must be between 1 and 65535"; + } + if (p.authenticate && !p.login.trim()) return "Proxy login is empty"; + return null; +}