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 <noreply@anthropic.com>
This commit is contained in:
l0ng-ai
2026-10-29 10:19:11 +08:00
co-authored by Claude
parent 75e7d582d4
commit c1dece1164
27 changed files with 1339 additions and 65 deletions
+1 -1
View File
@@ -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"] }
+1
View File
@@ -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;
+7 -2
View File
@@ -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<OsString>)> {
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
}
+365
View File
@@ -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<String> {
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<Item = String> + '_ {
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<String> {
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<OsString>)> {
let Some(url) = self.proxy_url() else { return Vec::new() };
let mut no_proxy: Vec<String> = 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<String> {
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<Option<Arc<ProxySettings>>> = 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<ProxySettings> {
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);
}
}
+4
View File
@@ -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<String, CustomKeymap>,
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(),
+8 -2
View File
@@ -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<ide_remote::RemoteService>,
/// 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).
+13 -4
View File
@@ -43,10 +43,10 @@ pub fn current(host: &Host, window: &str) -> Option<ProjectInfo> {
/// 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<ide_workspace::ProjectRoots> {
@@ -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 &current {
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 })
}
+50
View File
@@ -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<Option<serde_json::Value>> {
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))
}
}
+74
View File
@@ -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<Host> {
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<std::path::PathBuf> {
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);
}
+187
View File
@@ -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<reqwest::Client> {
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<reqwest::Client> {
static CLIENT: Mutex<Option<(Arc<ProxySettings>, 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<String> {
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<String>) {
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<String> {
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);
}
}
+2 -41
View File
@@ -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<reqwest::Client> {
static CLIENT: OnceLock<Result<reqwest::Client, String>> = 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<HttpResponse> {
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::*;
+3 -1
View File
@@ -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;
+6 -6
View File
@@ -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<String, String> {
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<String, String>) -> 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)?;
}
+2
View File
@@ -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"]);
}
+3 -5
View File
@@ -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 })
}
+8
View File
@@ -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<String> {
ide_http::client::check_connection(&proxy, &url, &ide_core::CancellationToken::new()).await
}
+1
View File
@@ -394,6 +394,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+2 -1
View File
@@ -295,7 +295,8 @@ fn updater(app: &AppHandle) -> IdeResult<Option<tauri_plugin_updater::Updater>>
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()))?;
+33
View File
@@ -850,6 +850,11 @@ export const commands = {
httpHistory: () => typedError<HttpHistoryEntry[], IdeError>(__TAURI_INVOKE("http_history")),
httpHistoryGet: (id: string) => typedError<HttpExchange, IdeError>(__TAURI_INVOKE("http_history_get", { id })),
httpHistoryClear: () => typedError<null, IdeError>(__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<string, IdeError>(__TAURI_INVOKE("proxy_check_connection", { proxy, url })),
dbSources: () => typedError<DataSource[], IdeError>(__TAURI_INVOKE("db_sources")),
dbSaveSources: (sources: DataSource[]) => typedError<null, IdeError>(__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). */
+5
View File
@@ -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<string, string[]>;
customKeymaps: Record<string, CustomKeymapSettings>;
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),
@@ -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;
}
+3 -1
View File
@@ -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",
+297
View File
@@ -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<ProxySettingsValid>) => void] {
const [proxy] = useSetting<ProxySettingsValid>("proxy", "global");
const { pageId } = usePageContext();
const update = (patch: Partial<ProxySettingsValid>) => {
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 (
<label className={clsx(styles.radio, disabled && styles.disabled)}>
<input type="radio" name={name} checked={checked} disabled={disabled} onChange={onSelect} />
<SearchLabel text={label} />
</label>
);
}
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 (
<div className={styles.page}>
<div className={styles.modes} role="radiogroup" aria-label={tx("Proxy")}>
{MODES.map((m) => (
<div key={m.value}>
<Radio
name={name}
checked={proxy.mode === m.value}
onSelect={() => update({ mode: m.value })}
label={m.label}
/>
{m.hint && (
<div className={styles.hint}>
<T>{m.hint}</T>
</div>
)}
</div>
))}
</div>
<div className={styles.manual}>
<div className={styles.kinds} role="radiogroup" aria-label={tx("Proxy type")}>
{KINDS.map((k) => (
<Radio
key={k.value}
name={kindName}
checked={proxy.kind === k.value}
disabled={!manual}
onSelect={() => update(withProxyKind(proxy, k.value))}
label={k.label}
/>
))}
</div>
<div className={styles.hostRow}>
<Row label="Host name" htmlFor={ids.host}>
<Input
id={ids.host}
className={styles.host}
disabled={!manual}
value={proxy.host}
onChange={(e) => update({ host: e.target.value })}
/>
</Row>
<Row label="Port number" htmlFor={ids.port}>
{manual ? (
<NumberInput
id={ids.port}
value={proxy.port}
onChange={(port) => update({ port })}
{...PROXY_PORT_LIMITS}
integer
aria-label="Port number"
/>
) : (
<Input id={ids.port} className={styles.port} value={String(proxy.port)} disabled readOnly />
)}
</Row>
</div>
<Row
label="No proxy for"
htmlFor={ids.noProxy}
hint="Comma-separated host names; * matches any characters, e.g. *.example.com, 192.168.*"
>
<Input
id={ids.noProxy}
className={styles.wide}
disabled={!manual}
value={proxy.noProxy}
onChange={(e) => update({ noProxy: e.target.value })}
/>
</Row>
<div className={styles.toggle}>
<Checkbox
checked={proxy.authenticate}
disabled={!manual}
onChange={(authenticate) => update({ authenticate })}
label={<SearchLabel text="Proxy authentication" />}
/>
</div>
<div className={styles.auth}>
<Row label="Login" htmlFor={ids.login}>
<Input
id={ids.login}
className={styles.host}
disabled={!auth}
value={proxy.login}
onChange={(e) => update({ login: e.target.value })}
/>
</Row>
<Row label="Password" htmlFor={ids.password}>
<Input
id={ids.password}
type="password"
className={styles.host}
disabled={!auth}
value={proxy.password}
onChange={(e) => update({ password: e.target.value })}
/>
</Row>
</div>
{problem && (
<div className={styles.error} role="alert">
<T>{problem}</T>
</div>
)}
</div>
<CheckConnection proxy={proxy} />
</div>
);
}
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<HTMLButtonElement>(null);
const urlId = useId();
const [open, setOpen] = useState(false);
const [url, setUrl] = useState("http://");
const [state, setState] = useState<CheckState>({ 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 (
<div className={styles.check}>
<Button
ref={anchor}
size="small"
disabled={!isTauri() || !!proxyProblem(proxy)}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<T>Check connection…</T>
</Button>
<Popup
open={open}
onClose={() => setOpen(false)}
anchor={anchor}
placement="bottom-start"
offset={4}
aria-label="Check connection"
>
<form
className={styles.popup}
onSubmit={(e) => {
e.preventDefault();
if (!running && url.trim()) void check();
}}
>
<label htmlFor={urlId}>
<T>Enter any URL to check connection to:</T>
</label>
<span className={styles.urlRow}>
<Input id={urlId} autoFocus className={styles.wide} value={url} onChange={(e) => setUrl(e.target.value)} />
<Button size="small" type="submit" disabled={running || !url.trim()}>
<T>Check</T>
</Button>
</span>
<span
role="status"
className={clsx(
styles.result,
state.status === "ok" && styles.ok,
state.status === "failed" && styles.error,
)}
>
{state.status === "ok" || state.status === "failed" ? state.text : running ? t("Connecting…") : ""}
</span>
</form>
</Popup>
</div>
);
}
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,
},
],
},
],
};
+24 -1
View File
@@ -4664,5 +4664,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": "代理登录名为空"
}
+2
View File
@@ -155,6 +155,7 @@ const UNTRANSLATED = new Set<string>([
"GOPATH",
"GOROOT",
"HEAD",
"HTTP",
"JDK",
"JDK 1.4.x",
"JDK 5 - 8",
@@ -162,6 +163,7 @@ const UNTRANSLATED = new Set<string>([
"NULL",
"ORDER BY",
"SDK",
"SOCKS",
"SSH/SSL",
"SSL",
"TODO",
+68
View File
@@ -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> = {}): 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();
});
});
+82
View File
@@ -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<ProxyKindValid, number> = {
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<string, unknown>;
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;
}