mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-17 16:37:20 +02:00
refactor: better proxy clipboard autofill
This commit is contained in:
@@ -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 () => {
|
test("About exposes a searchable, responsive third-party license inventory", async () => {
|
||||||
await withApp("ui-about-licenses", async (app) => {
|
await withApp("ui-about-licenses", async (app) => {
|
||||||
await app.clickSelector('[aria-label="More"]');
|
await app.clickSelector('[aria-label="More"]');
|
||||||
|
|||||||
+2
-1
@@ -10,10 +10,11 @@
|
|||||||
"prebuild": "pnpm licenses:generate",
|
"prebuild": "pnpm licenses:generate",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"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:themes": "node --test src/lib/themes.test.mjs",
|
||||||
"test:window-decorations": "node --test src/lib/window-decorations.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: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: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",
|
"test:xray-packaging": "node --test src-tauri/download-xray.test.mjs",
|
||||||
"licenses:generate": "node scripts/generate-licenses.mjs",
|
"licenses:generate": "node scripts/generate-licenses.mjs",
|
||||||
|
|||||||
+106
-47
@@ -1375,53 +1375,7 @@ impl ProxyManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 4 parts: could be host:port:user:pass OR user:pass:host:port
|
// 4 parts: could be host:port:user:pass OR user:pass:host:port
|
||||||
4 => {
|
4 => Self::parse_colon_separated_quad(&parts, "http", line),
|
||||||
// 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(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => ProxyParseResult::Invalid {
|
_ => ProxyParseResult::Invalid {
|
||||||
line: line.to_string(),
|
line: line.to_string(),
|
||||||
reason: format!("Unexpected format with {} parts", parts.len()),
|
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
|
// Try to parse URL format: protocol://username:password@host:port
|
||||||
fn try_parse_url_format(line: &str) -> Option<ProxyParseResult> {
|
fn try_parse_url_format(line: &str) -> Option<ProxyParseResult> {
|
||||||
if line.starts_with("vless://") {
|
if line.starts_with("vless://") {
|
||||||
@@ -1500,6 +1499,15 @@ impl ProxyManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
// No auth, just host:port
|
||||||
if let Some(colon_pos) = rest.rfind(':') {
|
if let Some(colon_pos) = rest.rfind(':') {
|
||||||
let host = &rest[..colon_pos];
|
let host = &rest[..colon_pos];
|
||||||
@@ -3876,6 +3884,57 @@ mod tests {
|
|||||||
_ => panic!("Expected Parsed"),
|
_ => 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
|
// Ambiguous: both positions could be ports
|
||||||
let results = ProxyManager::parse_txt_proxies("1234:5678:9012:3456\n");
|
let results = ProxyManager::parse_txt_proxies("1234:5678:9012:3456\n");
|
||||||
match &results[0] {
|
match &results[0] {
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { translateBackendError } from "@/lib/backend-errors";
|
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";
|
import { RippleButton } from "./ui/ripple";
|
||||||
|
|
||||||
interface ProxyFormData {
|
interface ProxyFormData {
|
||||||
@@ -202,6 +203,48 @@ export function ProxyFormDialog({
|
|||||||
}
|
}
|
||||||
}, [isSubmitting, onClose]);
|
}, [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 isVless = form.proxy_type === "vless";
|
||||||
const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null;
|
const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null;
|
||||||
|
|
||||||
@@ -259,6 +302,7 @@ export function ProxyFormDialog({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setForm({ ...form, name: e.target.value });
|
setForm({ ...form, name: e.target.value });
|
||||||
}}
|
}}
|
||||||
|
onPaste={handleProxyPaste}
|
||||||
placeholder={t("proxies.form.namePlaceholder")}
|
placeholder={t("proxies.form.namePlaceholder")}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
@@ -303,6 +347,7 @@ export function ProxyFormDialog({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setForm({ ...form, vless_uri: e.target.value });
|
setForm({ ...form, vless_uri: e.target.value });
|
||||||
}}
|
}}
|
||||||
|
onPaste={handleProxyPaste}
|
||||||
placeholder={t("proxies.form.vlessUriPlaceholder")}
|
placeholder={t("proxies.form.vlessUriPlaceholder")}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
aria-invalid={hasInvalidVlessUri}
|
aria-invalid={hasInvalidVlessUri}
|
||||||
@@ -337,6 +382,7 @@ export function ProxyFormDialog({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setForm({ ...form, host: e.target.value });
|
setForm({ ...form, host: e.target.value });
|
||||||
}}
|
}}
|
||||||
|
onPaste={handleProxyPaste}
|
||||||
placeholder={t("proxies.form.hostPlaceholder")}
|
placeholder={t("proxies.form.hostPlaceholder")}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
@@ -354,6 +400,7 @@ export function ProxyFormDialog({
|
|||||||
port: Number.parseInt(e.target.value, 10) || 0,
|
port: Number.parseInt(e.target.value, 10) || 0,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onPaste={handleProxyPaste}
|
||||||
placeholder={t("proxies.form.portPlaceholder")}
|
placeholder={t("proxies.form.portPlaceholder")}
|
||||||
min="1"
|
min="1"
|
||||||
max="65535"
|
max="65535"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { StepTransition } from "@/components/ui/step-transition";
|
import { StepTransition } from "@/components/ui/step-transition";
|
||||||
import { getCurrentOS } from "@/lib/browser-utils";
|
import { getCurrentOS } from "@/lib/browser-utils";
|
||||||
|
import { resolveAmbiguousProxyLine } from "@/lib/proxy-string";
|
||||||
import type {
|
import type {
|
||||||
ParsedProxyLine,
|
ParsedProxyLine,
|
||||||
ProxyImportResult,
|
ProxyImportResult,
|
||||||
@@ -265,31 +266,12 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleResolveAmbiguous = useCallback(() => {
|
const handleResolveAmbiguous = useCallback(() => {
|
||||||
// Convert ambiguous proxies to parsed based on selected format
|
const resolved = ambiguousProxies.flatMap((p) => {
|
||||||
const resolved: ParsedProxyLine[] = ambiguousProxies
|
const parsed = p.selectedFormat
|
||||||
.filter((p) => p.selectedFormat)
|
? resolveAmbiguousProxyLine(p.line, p.selectedFormat)
|
||||||
.map((p) => {
|
: null;
|
||||||
const parts = p.line.split(":");
|
return parsed ? [parsed] : [];
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setParsedProxies((prev) => [...prev, ...resolved]);
|
setParsedProxies((prev) => [...prev, ...resolved]);
|
||||||
setStep("preview");
|
setStep("preview");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user