feat(settings): HTTP Proxy settings for the IDE's outgoing connections (#194)

* 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 <[email protected]>

* fix(host): Allow the Unix-only post-start test helpers on Windows

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
Цей коміт міститься в:
l0ng-aiтаClaude authored and GitHub committed 2026-10-29 10:43:30 +08:00
1 джерело c8e3fe9d2e
коміт 9d4e778c7b
28 files changed
+1343 -65

No files matched your search

+33
Переглянути файл
@@ -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
Переглянути файл
@@ -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),
+88
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -4685,5 +4685,28 @@
"Code insight needs the index; it can't be stopped": "代码洞察依赖索引,无法停止",
"Waiting for other tasks": "正在等待其他任务",
"This task can't be cancelled": "此任务无法取消",
"Cancelled": "已取消"
"Cancelled": "已取消",
"No proxy": "不使用代理",
"Auto-detect proxy settings": "自动检测代理设置",
"Uses the system proxy: the HTTP_PROXY, HTTPS_PROXY and NO_PROXY environment variables.": "使用系统代理:HTTP_PROXY、HTTPS_PROXY 和 NO_PROXY 环境变量。",
"Manual proxy configuration": "手动配置代理",
"Proxy": "代理",
"Proxy type": "代理类型",
"Host name": "主机名",
"Port number": "端口号",
"No proxy for": "不对以下主机使用代理",
"Comma-separated host names; * matches any characters, e.g. *.example.com, 192.168.*": "以逗号分隔的主机名;* 匹配任意字符,例如 *.example.com, 192.168.*",
"Proxy authentication": "代理身份验证",
"Login": "登录名",
"Connection successful: {status}": "连接成功:{status}",
"Problem with connection: {message}": "连接出现问题:{message}",
"Check connection…": "检查连接…",
"Check connection": "检查连接",
"Enter any URL to check connection to:": "输入要检查连接的任意 URL:",
"Check": "检查",
"HTTP Proxy": "HTTP 代理",
"Proxy host is empty": "代理主机为空",
"Invalid proxy host": "代理主机无效",
"Proxy port must be between 1 and 65535": "代理端口必须介于 1 和 65535 之间",
"Proxy login is empty": "代理登录名为空"
}
+2
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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;
}