дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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]>
Цей коміт міститься в:
1 джерело
c8e3fe9d2e
коміт
9d4e778c7b
28 files changed
+1343
-65
No files matched your search
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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": "代理登录名为空"
|
||||
}
|
||||
@@ -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
Заблокувати користувача