refactor: better proxy clipboard autofill

This commit is contained in:
zhom
2026-08-16 19:49:54 +04:00
parent a0175eab0d
commit 2be0d4df0b
7 changed files with 501 additions and 74 deletions
+82
View File
@@ -556,6 +556,88 @@ test("VLESS proxy form keeps the share URI as one clear, validated input", async
});
});
test("pasting a proxy string into the form fills every field", async () => {
await withApp("ui-proxy-form-paste", async (app) => {
// Dispatched rather than typed: the point is that the paste is spread
// across the form instead of landing whole in the field it was dropped on,
// and only a real ClipboardEvent carries the text the handler reads.
const paste = (selector, text) =>
app.execute(
`const field = document.querySelector(arguments[0]);
const data = new DataTransfer();
data.setData("text/plain", arguments[1]);
field.focus();
return field.dispatchEvent(
new ClipboardEvent("paste", {
bubbles: true,
cancelable: true,
clipboardData: data,
}),
);`,
[selector, text],
);
const fieldValues = () =>
app.execute(
`return ["#proxy-name", "#proxy-host", "#proxy-port", "#proxy-username", "#proxy-password"]
.map((selector) => document.querySelector(selector)?.value ?? null);`,
);
await app.clickSelector('[aria-label="Network"]');
await app.waitForText("New proxy");
await app.clickSelector('[aria-label="New proxy"]');
await app.waitForText("Add Proxy");
await paste("#proxy-host", "socks5://carol:s3cret@1.2.3.4:1080");
await app.waitFor(async () => (await fieldValues())[1] === "1.2.3.4", {
description: "host filled from the pasted proxy",
});
assert.deepEqual(await fieldValues(), [
"1.2.3.4:1080",
"1.2.3.4",
"1080",
"carol",
"s3cret",
]);
assert.equal(
await app.execute(
`return document.querySelector("#proxy-type")?.textContent?.trim();`,
),
"SOCKS5",
);
// No scheme in the line, so the type falls back to HTTP.
await paste("#proxy-name", "5.6.7.8:8080:dave:hunter2");
await app.waitFor(async () => (await fieldValues())[1] === "5.6.7.8", {
description: "scheme-less proxy string parsed",
});
assert.deepEqual((await fieldValues()).slice(1), [
"5.6.7.8",
"8080",
"dave",
"hunter2",
]);
assert.equal(
await app.execute(
`return document.querySelector("#proxy-type")?.textContent?.trim();`,
),
"HTTP",
);
// A bare hostname is not a proxy string, so the form is left alone and the
// browser's own paste stands.
await paste("#proxy-host", "proxy.example.com");
// Settle the parse round-trip, so "nothing changed" isn't just "nothing
// has come back yet".
await app.invoke("get_stored_proxies");
assert.deepEqual((await fieldValues()).slice(1), [
"5.6.7.8",
"8080",
"dave",
"hunter2",
]);
});
});
test("About exposes a searchable, responsive third-party license inventory", async () => {
await withApp("ui-about-licenses", async (app) => {
await app.clickSelector('[aria-label="More"]');
+2 -1
View File
@@ -10,10 +10,11 @@
"prebuild": "pnpm licenses:generate",
"build": "next build",
"start": "next start",
"test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:proxy-string && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"test:themes": "node --test src/lib/themes.test.mjs",
"test:window-decorations": "node --test src/lib/window-decorations.test.mjs",
"test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs",
"test:proxy-string": "node --test src/lib/proxy-string.test.mjs",
"test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check",
"test:xray-packaging": "node --test src-tauri/download-xray.test.mjs",
"licenses:generate": "node scripts/generate-licenses.mjs",
+106 -47
View File
@@ -1375,53 +1375,7 @@ impl ProxyManager {
}
}
// 4 parts: could be host:port:user:pass OR user:pass:host:port
4 => {
// Try to detect which format
let port_at_1 = parts[1].parse::<u16>().is_ok();
let port_at_3 = parts[3].parse::<u16>().is_ok();
match (port_at_1, port_at_3) {
// host:port:user:pass
(true, false) => {
let port = parts[1].parse::<u16>().unwrap();
ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: "http".to_string(),
host: parts[0].to_string(),
port,
username: Some(parts[2].to_string()),
password: Some(parts[3].to_string()),
vless_uri: None,
original_line: line.to_string(),
})
}
// user:pass:host:port
(false, true) => {
let port = parts[3].parse::<u16>().unwrap();
ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: "http".to_string(),
host: parts[2].to_string(),
port,
username: Some(parts[0].to_string()),
password: Some(parts[1].to_string()),
vless_uri: None,
original_line: line.to_string(),
})
}
// Both could be ports - ambiguous
(true, true) => ProxyParseResult::Ambiguous {
line: line.to_string(),
possible_formats: vec![
"host:port:username:password".to_string(),
"username:password:host:port".to_string(),
],
},
// Neither is a valid port
(false, false) => ProxyParseResult::Invalid {
line: line.to_string(),
reason: "No valid port number found".to_string(),
},
}
}
4 => Self::parse_colon_separated_quad(&parts, "http", line),
_ => ProxyParseResult::Invalid {
line: line.to_string(),
reason: format!("Unexpected format with {} parts", parts.len()),
@@ -1429,6 +1383,51 @@ impl ProxyManager {
}
}
// Resolve a four-part colon-separated body, which is either
// host:port:username:password or username:password:host:port. The port
// position tells the two apart; when both positions parse as a port the
// caller has to ask the user.
fn parse_colon_separated_quad(parts: &[&str], proxy_type: &str, line: &str) -> ProxyParseResult {
let port_at_1 = parts[1].parse::<u16>().ok();
let port_at_3 = parts[3].parse::<u16>().ok();
match (port_at_1, port_at_3) {
// host:port:user:pass
(Some(port), None) => ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: proxy_type.to_string(),
host: parts[0].to_string(),
port,
username: Some(parts[2].to_string()),
password: Some(parts[3].to_string()),
vless_uri: None,
original_line: line.to_string(),
}),
// user:pass:host:port
(None, Some(port)) => ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: proxy_type.to_string(),
host: parts[2].to_string(),
port,
username: Some(parts[0].to_string()),
password: Some(parts[1].to_string()),
vless_uri: None,
original_line: line.to_string(),
}),
// Both could be ports - ambiguous
(Some(_), Some(_)) => ProxyParseResult::Ambiguous {
line: line.to_string(),
possible_formats: vec![
"host:port:username:password".to_string(),
"username:password:host:port".to_string(),
],
},
// Neither is a valid port
(None, None) => ProxyParseResult::Invalid {
line: line.to_string(),
reason: "No valid port number found".to_string(),
},
}
}
// Try to parse URL format: protocol://username:password@host:port
fn try_parse_url_format(line: &str) -> Option<ProxyParseResult> {
if line.starts_with("vless://") {
@@ -1500,6 +1499,15 @@ impl ProxyManager {
}
}
} else {
// Vendors also hand out the colon-separated body behind a scheme, as in
// socks5://host:port:user:pass, so try that before plain host:port. An
// IPv6 literal splits into four too ("[", "", "1]", "8080" for [::1]:8080),
// so require every field to be populated before reading it that way.
let parts: Vec<&str> = rest.split(':').collect();
if parts.len() == 4 && parts.iter().all(|part| !part.is_empty()) {
return Some(Self::parse_colon_separated_quad(&parts, protocol, line));
}
// No auth, just host:port
if let Some(colon_pos) = rest.rfind(':') {
let host = &rest[..colon_pos];
@@ -3876,6 +3884,57 @@ mod tests {
_ => panic!("Expected Parsed"),
}
// Scheme in front of the colon-separated body
let results = ProxyManager::parse_txt_proxies("socks5://1.2.3.4:1080:admin:secret\n");
match &results[0] {
ProxyParseResult::Parsed(p) => {
assert_eq!(p.proxy_type, "socks5");
assert_eq!(p.host, "1.2.3.4");
assert_eq!(p.port, 1080);
assert_eq!(p.username.as_deref(), Some("admin"));
assert_eq!(p.password.as_deref(), Some("secret"));
}
_ => panic!("Expected Parsed"),
}
// Same, with the credentials in front
let results = ProxyManager::parse_txt_proxies("https://admin:secret:proxy.com:8443\n");
match &results[0] {
ProxyParseResult::Parsed(p) => {
assert_eq!(p.proxy_type, "https");
assert_eq!(p.host, "proxy.com");
assert_eq!(p.port, 8443);
assert_eq!(p.username.as_deref(), Some("admin"));
assert_eq!(p.password.as_deref(), Some("secret"));
}
_ => panic!("Expected Parsed"),
}
// An IPv6 literal splits into four parts as well, and must not be read as
// the colon-separated form
let results = ProxyManager::parse_txt_proxies("http://[::1]:8080\n");
match &results[0] {
ProxyParseResult::Parsed(p) => {
assert_eq!(p.host, "[::1]");
assert_eq!(p.port, 8080);
assert!(p.username.is_none());
}
_ => panic!("Expected Parsed"),
}
// A scheme-prefixed body that is ambiguous stays ambiguous
let results = ProxyManager::parse_txt_proxies("socks5://1234:5678:9012:3456\n");
match &results[0] {
ProxyParseResult::Ambiguous {
line,
possible_formats,
} => {
assert_eq!(line, "socks5://1234:5678:9012:3456");
assert_eq!(possible_formats.len(), 2);
}
_ => panic!("Expected Ambiguous"),
}
// Ambiguous: both positions could be ports
let results = ProxyManager::parse_txt_proxies("1234:5678:9012:3456\n");
match &results[0] {
+48 -1
View File
@@ -23,7 +23,8 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { translateBackendError } from "@/lib/backend-errors";
import type { StoredProxy } from "@/types";
import { pickParsedProxy } from "@/lib/proxy-string";
import type { ProxyParseResult, StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxyFormData {
@@ -202,6 +203,48 @@ export function ProxyFormDialog({
}
}, [isSubmitting, onClose]);
// Proxies are copied around as one string — `socks5://user:pass@host:1080`,
// `host:1080:user:pass`, and a dozen variants of both — so a paste into any
// one field is almost never meant for that field alone. Hand the clipboard to
// the same Rust parser the import dialog uses and spread the result across
// the form. The default paste is left alone until the answer comes back, so a
// string that isn't a proxy (a hostname, a port) lands where it was dropped.
const handleProxyPaste = useCallback(
(event: React.ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const content = event.clipboardData.getData("text").trim();
if (!content) {
return;
}
// Captured before the browser applies the paste, so a proxy string
// dropped into the empty name field names the proxy after its endpoint
// instead of keeping the raw line.
const nameBeforePaste = form.name.trim();
void invoke<ProxyParseResult[]>("parse_txt_proxies", { content })
.then((results) => {
const parsed = pickParsedProxy(results);
if (!parsed) {
return;
}
setForm((previous) => ({
...previous,
name: nameBeforePaste || `${parsed.host}:${parsed.port}`,
proxy_type: parsed.proxy_type,
host: parsed.host,
port: parsed.port,
username: parsed.username ?? "",
password: parsed.password ?? "",
vless_uri: parsed.vless_uri ?? "",
}));
})
.catch((error: unknown) => {
console.error("Failed to parse pasted proxy:", error);
});
},
[form.name],
);
const isVless = form.proxy_type === "vless";
const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null;
@@ -259,6 +302,7 @@ export function ProxyFormDialog({
onChange={(e) => {
setForm({ ...form, name: e.target.value });
}}
onPaste={handleProxyPaste}
placeholder={t("proxies.form.namePlaceholder")}
disabled={isSubmitting}
/>
@@ -303,6 +347,7 @@ export function ProxyFormDialog({
onChange={(e) => {
setForm({ ...form, vless_uri: e.target.value });
}}
onPaste={handleProxyPaste}
placeholder={t("proxies.form.vlessUriPlaceholder")}
disabled={isSubmitting}
aria-invalid={hasInvalidVlessUri}
@@ -337,6 +382,7 @@ export function ProxyFormDialog({
onChange={(e) => {
setForm({ ...form, host: e.target.value });
}}
onPaste={handleProxyPaste}
placeholder={t("proxies.form.hostPlaceholder")}
disabled={isSubmitting}
/>
@@ -354,6 +400,7 @@ export function ProxyFormDialog({
port: Number.parseInt(e.target.value, 10) || 0,
});
}}
onPaste={handleProxyPaste}
placeholder={t("proxies.form.portPlaceholder")}
min="1"
max="65535"
+7 -25
View File
@@ -20,6 +20,7 @@ import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { StepTransition } from "@/components/ui/step-transition";
import { getCurrentOS } from "@/lib/browser-utils";
import { resolveAmbiguousProxyLine } from "@/lib/proxy-string";
import type {
ParsedProxyLine,
ProxyImportResult,
@@ -265,31 +266,12 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
);
const handleResolveAmbiguous = useCallback(() => {
// Convert ambiguous proxies to parsed based on selected format
const resolved: ParsedProxyLine[] = ambiguousProxies
.filter((p) => p.selectedFormat)
.map((p) => {
const parts = p.line.split(":");
if (p.selectedFormat === "host:port:username:password") {
return {
proxy_type: "http",
host: parts[0],
port: Number.parseInt(parts[1], 10),
username: parts[2],
password: parts[3],
original_line: p.line,
};
}
// username:password:host:port
return {
proxy_type: "http",
host: parts[2],
port: Number.parseInt(parts[3], 10),
username: parts[0],
password: parts[1],
original_line: p.line,
};
});
const resolved = ambiguousProxies.flatMap((p) => {
const parsed = p.selectedFormat
? resolveAmbiguousProxyLine(p.line, p.selectedFormat)
: null;
return parsed ? [parsed] : [];
});
setParsedProxies((prev) => [...prev, ...resolved]);
setStep("preview");
+129
View File
@@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
CREDENTIALS_FIRST_FORMAT,
HOST_FIRST_FORMAT,
pickParsedProxy,
resolveAmbiguousProxyLine,
splitProxyScheme,
} from "./proxy-string.ts";
/**
* The formats themselves are exercised in Rust
* (`proxy_manager::tests::test_proxy_txt_parsing_various_formats`). What is
* pinned here is the frontend's half: the scheme survives an ambiguous line,
* and a resolution that doesn't fit the line is refused rather than turned into
* a proxy pointing at somebody's password.
*/
test("a bare line is HTTP", () => {
assert.deepEqual(splitProxyScheme("1.2.3.4:8080"), {
proxyType: "http",
rest: "1.2.3.4:8080",
});
});
test("known schemes are recognised and normalised", () => {
assert.deepEqual(splitProxyScheme("SOCKS://1.2.3.4:1080"), {
proxyType: "socks5",
rest: "1.2.3.4:1080",
});
assert.equal(splitProxyScheme("shadowsocks://host:8388").proxyType, "ss");
});
test("an unknown scheme is left in the body rather than guessed at", () => {
assert.deepEqual(splitProxyScheme("ftp://1.2.3.4:21"), {
proxyType: "http",
rest: "ftp://1.2.3.4:21",
});
});
test("host-first resolution keeps the scheme", () => {
assert.deepEqual(
resolveAmbiguousProxyLine(
"socks5://1234:5678:9012:3456",
HOST_FIRST_FORMAT,
),
{
proxy_type: "socks5",
host: "1234",
port: 5678,
username: "9012",
password: "3456",
original_line: "socks5://1234:5678:9012:3456",
},
);
});
test("credentials-first resolution reads the tail as the endpoint", () => {
assert.deepEqual(
resolveAmbiguousProxyLine("1234:5678:9012:3456", CREDENTIALS_FIRST_FORMAT),
{
proxy_type: "http",
host: "9012",
port: 3456,
username: "1234",
password: "5678",
original_line: "1234:5678:9012:3456",
},
);
});
test("a format that doesn't fit the line resolves to nothing", () => {
// 70000 is past the port range, so this ordering cannot be the right one.
assert.equal(
resolveAmbiguousProxyLine("host:70000:user:pass", HOST_FIRST_FORMAT),
null,
);
assert.equal(resolveAmbiguousProxyLine("host:8080", HOST_FIRST_FORMAT), null);
assert.equal(
resolveAmbiguousProxyLine("a:1:b:2", "host:port:user:password"),
null,
);
});
test("the first parsed line of a multi-line paste wins", () => {
const parsed = pickParsedProxy([
{ status: "invalid", line: "notaproxy", reason: "nope" },
{
status: "parsed",
proxy_type: "socks5",
host: "1.2.3.4",
port: 1080,
username: "u",
password: "p",
original_line: "socks5://u:p@1.2.3.4:1080",
},
{
status: "parsed",
proxy_type: "http",
host: "5.6.7.8",
port: 80,
original_line: "5.6.7.8:80",
},
]);
assert.equal(parsed?.host, "1.2.3.4");
assert.equal(parsed?.proxy_type, "socks5");
});
test("an ambiguous paste falls back to host:port:username:password", () => {
const parsed = pickParsedProxy([
{
status: "ambiguous",
line: "1234:5678:9012:3456",
possible_formats: [HOST_FIRST_FORMAT, CREDENTIALS_FIRST_FORMAT],
},
]);
assert.equal(parsed?.host, "1234");
assert.equal(parsed?.port, 5678);
});
test("nothing usable yields null so the plain paste stands", () => {
assert.equal(
pickParsedProxy([
{ status: "invalid", line: "proxy.example.com", reason: "" },
]),
null,
);
assert.equal(pickParsedProxy([]), null);
});
+127
View File
@@ -0,0 +1,127 @@
/**
* Reading a proxy out of a pasted line.
*
* The parser itself is Rust's `parse_txt_proxies`
* (`src-tauri/src/proxy_manager.rs`); both the import dialog and the add/edit
* form hand their clipboard text to it rather than re-implementing the format
* zoo. What is left for the frontend is the part the backend deliberately
* refuses to decide: `a:b:c:d` is either `host:port:username:password` or
* `username:password:host:port`, and when both middle fields parse as a port
* only the user knows which. The backend reports that as `ambiguous`; the
* functions below turn the user's answer back into a proxy.
*
* Kept free of runtime imports so `proxy-string.test.mjs` can load it directly.
*/
import type { ParsedProxyLine, ProxyParseResult } from "@/types";
/** URL schemes the Rust parser accepts, mapped onto the stored proxy type. */
const PROXY_SCHEMES: Record<string, string> = {
http: "http",
https: "https",
socks: "socks5",
socks4: "socks4",
socks5: "socks5",
ss: "ss",
shadowsocks: "ss",
vless: "vless",
};
/** What a line carrying no scheme is assumed to be. */
export const DEFAULT_PROXY_TYPE = "http";
export const HOST_FIRST_FORMAT = "host:port:username:password";
export const CREDENTIALS_FIRST_FORMAT = "username:password:host:port";
/**
* Separates `socks5://1.2.3.4:1080` into its scheme and body. An unknown or
* absent scheme leaves the body untouched and falls back to HTTP, which is what
* the backend does with a bare `host:port`.
*/
export function splitProxyScheme(line: string): {
proxyType: string;
rest: string;
} {
const separator = line.indexOf("://");
if (separator === -1) {
return { proxyType: DEFAULT_PROXY_TYPE, rest: line };
}
const proxyType = PROXY_SCHEMES[line.slice(0, separator).toLowerCase()];
return proxyType
? { proxyType, rest: line.slice(separator + 3) }
: { proxyType: DEFAULT_PROXY_TYPE, rest: line };
}
/**
* Builds a proxy from a four-part line once the user has said which of the two
* orderings it uses. Returns null when the chosen ordering doesn't actually fit
* the line, so a stale selection can't produce a proxy pointing at a password.
*/
export function resolveAmbiguousProxyLine(
line: string,
format: string,
): ParsedProxyLine | null {
const trimmed = line.trim();
const { proxyType, rest } = splitProxyScheme(trimmed);
const parts = rest.split(":");
if (parts.length !== 4) {
return null;
}
const hostFirst = format === HOST_FIRST_FORMAT;
if (!hostFirst && format !== CREDENTIALS_FIRST_FORMAT) {
return null;
}
const host = hostFirst ? parts[0] : parts[2];
const port = Number.parseInt(hostFirst ? parts[1] : parts[3], 10);
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
return null;
}
return {
proxy_type: proxyType,
host,
port,
username: hostFirst ? parts[2] : parts[0],
password: hostFirst ? parts[3] : parts[1],
original_line: trimmed,
};
}
/**
* Picks the proxy to use out of a parse of pasted text. Only the first usable
* line matters: the form holds one proxy, and a paste that happens to carry a
* whole list should still fill it in rather than do nothing.
*
* Ambiguous lines resolve as `host:port:username:password`, the ordering the
* import dialog offers first and the one vendors overwhelmingly ship.
*/
export function pickParsedProxy(
results: ProxyParseResult[],
): ParsedProxyLine | null {
for (const result of results) {
if (result.status === "parsed") {
return {
proxy_type: result.proxy_type,
host: result.host,
port: result.port,
username: result.username,
password: result.password,
vless_uri: result.vless_uri,
original_line: result.original_line,
};
}
if (result.status === "ambiguous") {
const resolved = resolveAmbiguousProxyLine(
result.line,
HOST_FIRST_FORMAT,
);
if (resolved) {
return resolved;
}
}
}
return null;
}