From 2be0d4df0b4bc75a80f43c450d82b846c355ed5e Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:49:54 +0400 Subject: [PATCH] refactor: better proxy clipboard autofill --- e2e/tests/ui.test.mjs | 82 +++++++++++++ package.json | 3 +- src-tauri/src/proxy_manager.rs | 153 +++++++++++++++++-------- src/components/proxy-form-dialog.tsx | 49 +++++++- src/components/proxy-import-dialog.tsx | 32 ++---- src/lib/proxy-string.test.mjs | 129 +++++++++++++++++++++ src/lib/proxy-string.ts | 127 ++++++++++++++++++++ 7 files changed, 501 insertions(+), 74 deletions(-) create mode 100644 src/lib/proxy-string.test.mjs create mode 100644 src/lib/proxy-string.ts diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 905eb35..e7d80b6 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -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"]'); diff --git a/package.json b/package.json index e3d64ac..741883e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 202a7e0..c564ea5 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -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::().is_ok(); - let port_at_3 = parts[3].parse::().is_ok(); - - match (port_at_1, port_at_3) { - // host:port:user:pass - (true, false) => { - let port = parts[1].parse::().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::().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::().ok(); + let port_at_3 = parts[3].parse::().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 { 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] { diff --git a/src/components/proxy-form-dialog.tsx b/src/components/proxy-form-dialog.tsx index 066fc3c..cbe4ff9 100644 --- a/src/components/proxy-form-dialog.tsx +++ b/src/components/proxy-form-dialog.tsx @@ -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) => { + 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("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" diff --git a/src/components/proxy-import-dialog.tsx b/src/components/proxy-import-dialog.tsx index bed300b..69a5252 100644 --- a/src/components/proxy-import-dialog.tsx +++ b/src/components/proxy-import-dialog.tsx @@ -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"); diff --git a/src/lib/proxy-string.test.mjs b/src/lib/proxy-string.test.mjs new file mode 100644 index 0000000..611ffa0 --- /dev/null +++ b/src/lib/proxy-string.test.mjs @@ -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); +}); diff --git a/src/lib/proxy-string.ts b/src/lib/proxy-string.ts new file mode 100644 index 0000000..3347cf9 --- /dev/null +++ b/src/lib/proxy-string.ts @@ -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 = { + 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; +}