mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
+1
-1
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 ¤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 })
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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": "代理登录名为空"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user