feat: xray support

This commit is contained in:
zhom
2026-07-31 01:04:58 +04:00
parent 064bf297dd
commit 0a7d7803f2
112 changed files with 10291 additions and 772 deletions
+6 -6
View File
@@ -72,7 +72,7 @@ jobs:
with:
repository: ${{ vars.TAURI_WEBDRIVER_REPOSITORY }}
token: ${{ secrets.TAURI_WEBDRIVER_TOKEN || github.token }}
path: tauri-cross-platform-webdriver
path: tauri-wd
persist-credentials: false
- name: Install pnpm
@@ -98,7 +98,7 @@ jobs:
workspaces: |
donutbrowser/src-tauri -> target
donutbrowser/e2e/app -> target
tauri-cross-platform-webdriver -> target
tauri-wd -> target
- name: Install Tauri dependencies
if: runner.os == 'Linux'
@@ -147,7 +147,7 @@ jobs:
with:
repository: ${{ vars.TAURI_WEBDRIVER_REPOSITORY }}
token: ${{ secrets.TAURI_WEBDRIVER_TOKEN || github.token }}
path: tauri-cross-platform-webdriver
path: tauri-wd
persist-credentials: false
- name: Install pnpm
@@ -173,7 +173,7 @@ jobs:
workspaces: |
donutbrowser/src-tauri -> target
donutbrowser/e2e/app -> target
tauri-cross-platform-webdriver -> target
tauri-wd -> target
- name: Install app dependencies
working-directory: donutbrowser
@@ -214,7 +214,7 @@ jobs:
with:
repository: ${{ vars.TAURI_WEBDRIVER_REPOSITORY }}
token: ${{ secrets.TAURI_WEBDRIVER_TOKEN || github.token }}
path: tauri-cross-platform-webdriver
path: tauri-wd
persist-credentials: false
- name: Install pnpm
@@ -240,7 +240,7 @@ jobs:
workspaces: |
donutbrowser/src-tauri -> target
donutbrowser/e2e/app -> target
tauri-cross-platform-webdriver -> target
tauri-wd -> target
- name: Install Tauri dependencies
run: |
+5 -2
View File
@@ -67,13 +67,13 @@ jobs:
if: matrix.os == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev openvpn
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev openvpn unzip
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build frontend
run: pnpm next build
run: pnpm build
- name: Get host target
id: host_target
@@ -101,6 +101,9 @@ jobs:
chmod +x src-tauri/binaries/donut-proxy-${HOST_TARGET}
fi
- name: Download verified Xray-core sidecar
run: node src-tauri/download-xray.mjs --target ${{ steps.host_target.outputs.target }}
- name: Run rustfmt check
run: cargo fmt --all -- --check
working-directory: src-tauri
+8 -2
View File
@@ -124,7 +124,7 @@ jobs:
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config xdg-utils
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config unzip xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
@@ -139,7 +139,7 @@ jobs:
# from secrets explicitly — they are NOT inherited from the job env.
env:
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
run: pnpm exec next build
run: pnpm build
- name: Verify frontend dist exists
shell: bash
@@ -172,6 +172,9 @@ jobs:
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
fi
- name: Download verified Xray-core sidecar
run: node src-tauri/download-xray.mjs --target ${{ matrix.target }}
- name: Import Apple certificate
if: matrix.platform == 'macos-latest'
env:
@@ -245,6 +248,9 @@ jobs:
# Copy sidecar binaries
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
cp "src-tauri/binaries/xray-${{ matrix.target }}.exe" "$PORTABLE_DIR/xray.exe"
mkdir -p "$PORTABLE_DIR/licenses"
cp "src-tauri/binaries/xray-LICENSE.txt" "$PORTABLE_DIR/licenses/Xray-core-LICENSE.txt"
# The daemon is currently disabled (no Cargo bin target), so it isn't
# built. Copy it only if a build produced it, so the absent binary
# doesn't fail the job.
+8 -2
View File
@@ -131,7 +131,7 @@ jobs:
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config xdg-utils
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config unzip xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
@@ -146,7 +146,7 @@ jobs:
# from secrets explicitly — they are NOT inherited from the job env.
env:
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
run: pnpm exec next build
run: pnpm build
- name: Verify frontend dist exists
shell: bash
@@ -193,6 +193,9 @@ jobs:
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
fi
- name: Download verified Xray-core sidecar
run: node src-tauri/download-xray.mjs --target ${{ matrix.target }}
- name: Import Apple certificate
if: matrix.platform == 'macos-latest'
env:
@@ -259,6 +262,9 @@ jobs:
cp "src-tauri/target/${{ matrix.target }}/release/donutbrowser.exe" "$PORTABLE_DIR/Donut.exe"
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
cp "src-tauri/binaries/xray-${{ matrix.target }}.exe" "$PORTABLE_DIR/xray.exe"
mkdir -p "$PORTABLE_DIR/licenses"
cp "src-tauri/binaries/xray-LICENSE.txt" "$PORTABLE_DIR/licenses/Xray-core-LICENSE.txt"
# The daemon is currently disabled (no Cargo bin target), so it isn't
# built. Copy it only if a build produced it, so the absent binary
# doesn't fail the job.
+4
View File
@@ -18,6 +18,7 @@ donutbrowser/
│ ├── components/ # 50+ React components (dialogs, tables, UI)
│ ├── hooks/ # Event-driven React hooks
│ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, tr, vi, zh)
│ ├── generated/ # Build-generated third-party license inventory
│ ├── lib/ # Utilities (themes, toast, browser-utils)
│ └── types.ts # Shared TypeScript interfaces
├── src-tauri/ # Rust backend (Tauri)
@@ -34,6 +35,9 @@ donutbrowser/
│ │ ├── automation_rate_limiter.rs # Shared REST/MCP automation quota
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
│ │ ├── vpn/ # WireGuard tunnels
│ │ ├── xray/ # VLESS + XTLS Vision + REALITY config/URI support
│ │ ├── xray_worker_runner.rs # Xray-core sidecar lifecycle
│ │ ├── xray_worker_storage.rs # Private Xray worker state and runtime files
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
│ │ ├── downloader.rs # Browser binary downloader
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
+2
View File
@@ -2,6 +2,8 @@
extend-exclude = [
"src-tauri/src/territory_info.xml",
"src/i18n/locales/*.json",
# Dependency names and SPDX expressions are generated verbatim.
"src/generated/licenses.json",
# Auto-generated from commit subjects by release.yml; typos here originate
# in commit messages, which are immutable, so don't spell-check it.
"CHANGELOG.md",
+38 -31
View File
@@ -570,6 +570,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9"
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -1870,7 +1876,7 @@ name = "donutbrowser-e2e"
version = "0.1.0"
dependencies = [
"donutbrowser",
"tauri-plugin-cross-platform-webdriver",
"tauri-wd",
]
[[package]]
@@ -6637,36 +6643,6 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-cross-platform-webdriver"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"block2",
"cairo-rs",
"glib 0.21.5",
"gtk",
"javascriptcore-rs",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"objc2-web-kit",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tempfile",
"tokio",
"tracing",
"uuid",
"webkit2gtk",
"webview2-com",
"windows 0.61.3",
"windows-core 0.61.2",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.9"
@@ -6929,6 +6905,37 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-wd"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"base64 0.23.0",
"block2",
"cairo-rs",
"glib 0.21.5",
"gtk",
"image",
"javascriptcore-rs",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"objc2-web-kit",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tempfile",
"tokio",
"tracing",
"uuid",
"webkit2gtk",
"webview2-com",
"windows 0.61.3",
"windows-core 0.61.2",
]
[[package]]
name = "tauri-winres"
version = "0.3.6"
+1 -1
View File
@@ -6,4 +6,4 @@ publish = false
[dependencies]
donutbrowser-lib = { package = "donutbrowser", path = "../../src-tauri", features = ["e2e"] }
tauri-plugin-cross-platform-webdriver = { path = "../../../tauri-cross-platform-webdriver/crates/tauri-plugin-cross-platform-webdriver" }
tauri-wd = { path = "../../../tauri-wd/crates/tauri-wd" }
+1 -1
View File
@@ -2,6 +2,6 @@
fn main() {
donutbrowser_lib::run_with_builder(|builder| {
builder.plugin(tauri_plugin_cross_platform_webdriver::init())
builder.plugin(tauri_wd::init())
});
}
+56 -27
View File
@@ -159,6 +159,10 @@ export class AppSession {
});
}
if (this.seedVersionCache) {
const seededVersion =
typeof this.seedVersionCache === "string"
? this.seedVersionCache
: "150.0.7871.100";
const versionCache = path.join(
this.root,
"donut",
@@ -170,7 +174,7 @@ export class AppSession {
await writeFile(
versionCache,
`${JSON.stringify({
releases: [{ version: "150.0.7871.100", date: "2026-07-01" }],
releases: [{ version: seededVersion, date: "2026-07-01" }],
timestamp: Math.floor(Date.now() / 1000),
})}\n`,
{ flag: "wx" },
@@ -228,7 +232,7 @@ export class AppSession {
return this.session.execute(script, args);
}
async invoke(command, args = {}) {
async invoke(command, args = {}, timeoutMs = 330_000) {
assert.ok(this.session, `${this.name} is not started`);
const result = await this.session.executeAsync(
`
@@ -243,6 +247,7 @@ export class AppSession {
}));
`,
[command, args],
timeoutMs,
);
if (!result?.ok) {
throw new Error(
@@ -314,6 +319,27 @@ export class AppSession {
});
}
async clickElement(element, description = "element") {
await this.waitFor(
() =>
this.execute(
`
const node = arguments[0];
if (!(node instanceof Element) || !node.isConnected) return false;
node.scrollIntoView({ block: "center", inline: "center" });
const rect = node.getBoundingClientRect();
const x = Math.floor(rect.left + rect.width / 2);
const y = Math.floor(rect.top + rect.height / 2);
const hit = document.elementFromPoint(x, y);
return Boolean(hit && (hit === node || node.contains(hit)));
`,
[element],
),
{ description: `pointer-interactable ${description}` },
);
await this.session.click(element);
}
async clickText(
text,
{ exact = true, roles = ["button", "tab", "menuitem", "link"] } = {},
@@ -342,7 +368,7 @@ export class AppSession {
element,
`No visible interactive element matched ${JSON.stringify(text)}`,
);
await this.session.click(element);
await this.clickElement(element, JSON.stringify(text));
}
async clickTextIn(
@@ -380,7 +406,10 @@ export class AppSession {
element,
`No visible interactive element inside ${containerSelector} matched ${JSON.stringify(text)}`,
);
await this.session.click(element);
await this.clickElement(
element,
`${JSON.stringify(text)} inside ${containerSelector}`,
);
}
async clickSelector(selector) {
@@ -399,7 +428,7 @@ export class AppSession {
),
{ description: `visible selector ${selector}` },
);
await this.session.click(element);
await this.clickElement(element, selector);
}
async fillSelector(selector, value) {
@@ -421,28 +450,28 @@ export class AppSession {
alt = false,
shift = false,
}) {
await this.execute(
`
window.dispatchEvent(new KeyboardEvent("keydown", {
key: arguments[0],
code: arguments[1],
metaKey: arguments[2],
ctrlKey: arguments[3],
altKey: arguments[4],
shiftKey: arguments[5],
bubbles: true,
cancelable: true
}));
`,
[
key,
key.length === 1 ? `Key${key.toUpperCase()}` : key,
meta,
ctrl,
alt,
shift,
],
);
const modifiers = [
...(meta ? ["\uE03D"] : []),
...(ctrl ? ["\uE009"] : []),
...(alt ? ["\uE00A"] : []),
...(shift ? ["\uE008"] : []),
];
const value = key === "Escape" ? "\uE00C" : key;
const actions = [
...modifiers.map((modifier) => ({ type: "keyDown", value: modifier })),
{ type: "keyDown", value },
{ type: "keyUp", value },
...modifiers
.toReversed()
.map((modifier) => ({ type: "keyUp", value: modifier })),
];
try {
await this.session.command("POST", "/actions", {
actions: [{ type: "key", id: "keyboard", actions }],
});
} finally {
await this.session.command("DELETE", "/actions");
}
}
async capture(label) {
+59 -5
View File
@@ -1,7 +1,15 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { chmod, copyFile, cp, mkdir, writeFile } from "node:fs/promises";
import {
chmod,
copyFile,
cp,
mkdir,
rename,
rm,
writeFile,
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -69,6 +77,42 @@ async function cloneAppBundle(source, destination) {
}
}
async function cacheDownloadedWayfern(app, projectRoot, version) {
if (process.env.DONUT_E2E_WAYFERN_PATH) return;
const destination = defaultWayfernPath(projectRoot);
if (existsSync(destination)) return;
const installDir = path.join(
app.dataRoot,
"data",
"binaries",
"wayfern",
version,
);
const source =
process.platform === "darwin"
? path.join(installDir, "Wayfern.app")
: path.join(
installDir,
process.platform === "win32" ? "wayfern.exe" : "wayfern",
);
const staging = `${destination}.tmp-${process.pid}`;
await rm(staging, { recursive: true, force: true });
try {
if (process.platform === "darwin") {
await cloneAppBundle(source, staging);
} else {
await mkdir(path.dirname(staging), { recursive: true });
await copyFile(source, staging);
if (process.platform !== "win32") await chmod(staging, 0o755);
}
await rename(staging, destination);
} catch (error) {
await rm(staging, { recursive: true, force: true });
if (!existsSync(destination)) throw error;
}
}
export async function seedWayfern(dataRoot, wayfern) {
const installDir = path.join(
dataRoot,
@@ -130,10 +174,20 @@ export async function prepareWayfern(app, projectRoot) {
"No Wayfern build is published for this platform",
);
const version = current.versions[0];
await app.invoke("download_browser", {
browserStr: "wayfern",
version,
});
await app.session.setTimeouts({ script: 600_000 });
try {
await app.invoke(
"download_browser",
{
browserStr: "wayfern",
version,
},
620_000,
);
} finally {
await app.session.setTimeouts();
}
await cacheDownloadedWayfern(app, projectRoot, version);
return { version, source: "published download" };
}
+186 -5
View File
@@ -15,6 +15,7 @@ import {
readFile,
rename,
rm,
writeFile,
} from "node:fs/promises";
import http from "node:http";
import https from "node:https";
@@ -27,10 +28,7 @@ import { createSafeDiagnostics } from "./lib/diagnostics.mjs";
const dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(dirname, "..");
const webdriverRoot = path.resolve(
projectRoot,
"../tauri-cross-platform-webdriver",
);
const webdriverRoot = path.resolve(projectRoot, "../tauri-wd");
const isWindows = process.platform === "win32";
const executableSuffix = isWindows ? ".exe" : "";
const appBinary = path.join(
@@ -246,6 +244,7 @@ function buildAll() {
}
run("pnpm", ["build"], projectRoot);
run("pnpm", ["copy-proxy-binary"], projectRoot);
run(process.execPath, ["src-tauri/download-xray.mjs"], projectRoot);
run(
"cargo",
["build", "--locked", "--manifest-path", "e2e/app/Cargo.toml"],
@@ -520,6 +519,178 @@ function dockerAvailable() {
return !result.error && result.status === 0;
}
function hostRustTarget() {
const result = spawnSync("rustc", ["-vV"], {
encoding: "utf8",
timeout: 10_000,
});
if (result.error || result.status !== 0) {
throw new Error(
`Could not determine the host Rust target: ${result.error?.message ?? result.stderr?.trim() ?? `exit ${result.status}`}`,
);
}
const target = result.stdout.match(/^host:\s*(.+)$/m)?.[1]?.trim();
if (!target) {
throw new Error("rustc -vV did not report a host target");
}
return target;
}
function xrayBinaryPath() {
const target = hostRustTarget();
return path.join(
projectRoot,
"src-tauri",
"binaries",
`xray-${target}${target.includes("windows") ? ".exe" : ""}`,
);
}
async function waitForPort(port, timeoutMs, processRecord) {
const started = Date.now();
let lastError;
while (Date.now() - started < timeoutMs) {
if (processRecord?.process.exitCode !== null) {
throw new Error(
`${processRecord.name} exited early with ${processRecord.process.exitCode}; see ${processRecord.logPath}`,
);
}
try {
await new Promise((resolve, reject) => {
const socket = net.createConnection({ host: "127.0.0.1", port });
socket.setTimeout(1_000);
socket.once("connect", () => {
socket.destroy();
resolve();
});
socket.once("timeout", () => {
socket.destroy();
reject(new Error("connection timed out"));
});
socket.once("error", reject);
});
return;
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(
`Timed out waiting for 127.0.0.1:${port}: ${lastError?.message ?? lastError}`,
);
}
async function startXrayInfrastructure(runRoot, options, records) {
const executable = xrayBinaryPath();
if (!existsSync(executable)) {
throw new Error(
`The Xray-core E2E binary is missing: ${executable}. Run node src-tauri/download-xray.mjs first.`,
);
}
const keyResult = spawnSync(executable, ["x25519"], {
encoding: "utf8",
timeout: 10_000,
});
if (keyResult.error || keyResult.status !== 0) {
throw new Error(
`Xray-core key generation failed: ${keyResult.error?.message ?? keyResult.stderr?.trim() ?? `exit ${keyResult.status}`}`,
);
}
const privateKey = keyResult.stdout.match(/^PrivateKey:\s*(\S+)\s*$/m)?.[1];
const publicKey = keyResult.stdout.match(
/^(?:Password \(PublicKey\)|PublicKey):\s*(\S+)\s*$/m,
)?.[1];
if (!privateKey || !publicKey) {
throw new Error("Xray-core key generation returned an unknown format");
}
const port = await freePort();
const id = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
const shortId = "0123456789abcdef";
const serverName = "www.cloudflare.com";
const accessLog = path.join(runRoot, "logs", "xray-access.log");
const configPath = path.join(runRoot, "xray-server.json");
const config = {
log: {
access: accessLog,
loglevel: "warning",
},
inbounds: [
{
tag: "vless-reality",
listen: "127.0.0.1",
port,
protocol: "vless",
settings: {
clients: [{ id, flow: "xtls-rprx-vision" }],
decryption: "none",
},
streamSettings: {
network: "raw",
security: "reality",
realitySettings: {
show: false,
target: `${serverName}:443`,
xver: 0,
serverNames: [serverName],
privateKey,
shortIds: [shortId],
},
},
},
],
outbounds: [{ tag: "direct", protocol: "freedom" }],
};
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, {
mode: 0o600,
});
const validation = spawnSync(executable, ["run", "-test", "-c", configPath], {
encoding: "utf8",
timeout: 15_000,
});
if (validation.error || validation.status !== 0) {
throw new Error(
`Xray-core server configuration is invalid: ${validation.error?.message ?? validation.stderr?.trim() ?? `exit ${validation.status}`}`,
);
}
const server = startProcess(
"xray-server",
executable,
["run", "-c", configPath],
{
cwd: projectRoot,
env: process.env,
runRoot,
verbose: options.verbose,
},
);
records.push(server);
await waitForPort(port, 15_000, server);
const uri = new URL(`vless://${id}@127.0.0.1:${port}`);
uri.searchParams.set("encryption", "none");
uri.searchParams.set("flow", "xtls-rprx-vision");
uri.searchParams.set("security", "reality");
uri.searchParams.set("sni", serverName);
uri.searchParams.set("fp", "chrome");
uri.searchParams.set("pbk", publicKey);
uri.searchParams.set("sid", shortId);
uri.searchParams.set("spx", "/");
uri.searchParams.set("type", "tcp");
uri.searchParams.set("headerType", "none");
uri.hash = "Local Xray E2E";
return {
accessLog,
configPath,
privateKey,
uri: uri.href,
};
}
async function startWireGuardInfrastructure() {
if (!dockerAvailable()) {
throw new Error(
@@ -633,6 +804,7 @@ async function main() {
const records = [];
let fixture;
let wireGuard;
let xray;
let failed = false;
const sensitiveValues = [
"donut-e2e-sync-token-0123456789abcdef",
@@ -646,6 +818,9 @@ async function main() {
if (wireGuard) {
runDocker(["rm", "-f", wireGuard.name], { allowFailure: true });
}
if (xray) {
await rm(xray.configPath, { force: true });
}
if (!options.keep && !failed) {
await rm(runRoot, { recursive: true, force: true });
} else {
@@ -686,7 +861,7 @@ async function main() {
"--startup-timeout",
"120",
"--command-timeout",
"330",
"630",
"--log",
options.verbose ? "debug" : "info",
],
@@ -716,6 +891,10 @@ async function main() {
if (networkEnabled && process.env.DONUT_E2E_SKIP_VPN_TUNNEL !== "1") {
wireGuard = await startWireGuardInfrastructure();
}
if (networkEnabled) {
xray = await startXrayInfrastructure(runRoot, options, records);
sensitiveValues.push(xray.privateKey);
}
const localValues = await loadLocalValues([
"WAYFERN_TEST_TOKEN",
@@ -767,6 +946,8 @@ async function main() {
: "",
DONUT_E2E_WIREGUARD_TARGET_URL: wireGuard?.targetUrl ?? "",
DONUT_E2E_WIREGUARD_CONTAINER: wireGuard?.name ?? "",
DONUT_E2E_VLESS_URI: xray?.uri ?? "",
DONUT_E2E_XRAY_ACCESS_LOG: xray?.accessLog ?? "",
},
stdio: "inherit",
});
+11 -4
View File
@@ -7,7 +7,11 @@ import path from "node:path";
import test from "node:test";
import { appFromEnvironment } from "../lib/app.mjs";
import { CdpClient } from "../lib/cdp.mjs";
import { defaultWayfernPath, prepareWayfern } from "../lib/fixtures.mjs";
import {
defaultWayfernPath,
inspectWayfern,
prepareWayfern,
} from "../lib/fixtures.mjs";
const fixtureUrl = process.env.DONUT_E2E_FIXTURE_URL;
@@ -133,11 +137,14 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
const realTermsFile = realWayfernTermsPath();
const realTermsBefore = await snapshotFile(realTermsFile);
const hasLocalWayfern = existsSync(
defaultWayfernPath(process.env.DONUT_E2E_PROJECT_ROOT),
const localWayfernPath = defaultWayfernPath(
process.env.DONUT_E2E_PROJECT_ROOT,
);
const localWayfernVersion = existsSync(localWayfernPath)
? inspectWayfern(localWayfernPath).version
: null;
const app = appFromEnvironment("browser-wayfern", {
seedVersionCache: hasLocalWayfern,
seedVersionCache: localWayfernVersion ?? false,
wayfernTermsAccepted: false,
});
let cdp;
+131
View File
@@ -4,6 +4,9 @@ import path from "node:path";
import test from "node:test";
import { withApp } from "../lib/app.mjs";
const VLESS_URI =
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@127.0.0.1:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.example.com&fp=chrome&pbk=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc&sid=0123456789abcdef&spx=%2F&type=tcp&headerType=none#MCP";
async function jsonRequest(
url,
{ method = "GET", token, body, headers = {} } = {},
@@ -139,6 +142,49 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec
token: saved.api_token,
});
assert.equal(fetchedProxy.value.name, "REST Proxy");
const createdVless = await jsonRequest(`${base}/v1/proxies`, {
method: "POST",
token: saved.api_token,
body: {
name: "REST VLESS Reality",
proxy_settings: {
proxy_type: "vless",
host: "127.0.0.1",
port: 443,
username: null,
password: null,
vless_uri: VLESS_URI,
},
},
});
assert.equal(createdVless.response.status, 200);
assert.equal(createdVless.value.proxy_settings.vless_uri, VLESS_URI);
assert.equal(createdVless.value.proxy_settings.host, "127.0.0.1");
assert.equal(createdVless.value.proxy_settings.port, 443);
const vlessProxyId = createdVless.value.id;
const invalidVless = await jsonRequest(
`${base}/v1/proxies/${vlessProxyId}`,
{
method: "PUT",
token: saved.api_token,
body: {
proxy_settings: {
...createdVless.value.proxy_settings,
vless_uri: VLESS_URI.replace("security=reality", "security=tls"),
},
},
},
);
assert.equal(invalidVless.response.status, 400);
assert.match(JSON.stringify(invalidVless.value), /VLESS_CONFIG_INVALID/);
assert.equal(
(
await jsonRequest(`${base}/v1/proxies/${vlessProxyId}`, {
token: saved.api_token,
})
).value.proxy_settings.vless_uri,
VLESS_URI,
);
const imported = await jsonRequest(`${base}/v1/proxies/import`, {
method: "POST",
token: saved.api_token,
@@ -171,6 +217,15 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec
).response.status,
204,
);
assert.equal(
(
await jsonRequest(`${base}/v1/proxies/${vlessProxyId}`, {
method: "DELETE",
token: saved.api_token,
})
).response.status,
204,
);
for (const importedProxy of imported.value.proxies) {
await jsonRequest(`${base}/v1/proxies/${importedProxy.id}`, {
method: "DELETE",
@@ -257,6 +312,8 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
"create_profile",
"run_profile",
"list_proxies",
"create_proxy",
"update_proxy",
"get_page_content",
"get_interactive_elements",
]) {
@@ -276,6 +333,80 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
assert.equal(listed.value.error, undefined);
assert.ok(listed.value.result);
const createdVless = await jsonRequest(`${base}/mcp/${config.token}`, {
method: "POST",
headers: mcpHeaders,
body: {
jsonrpc: "2.0",
id: 4,
method: "tools/call",
params: {
name: "create_proxy",
arguments: {
name: "MCP VLESS Reality",
proxy_type: "vless",
vless_uri: VLESS_URI,
},
},
},
});
assert.equal(createdVless.response.status, 200);
assert.equal(createdVless.value.error, undefined);
let vlessProxy = (await app.invoke("get_stored_proxies")).find(
(proxy) => proxy.name === "MCP VLESS Reality",
);
assert.ok(vlessProxy);
assert.equal(vlessProxy.proxy_settings.proxy_type, "vless");
assert.equal(vlessProxy.proxy_settings.vless_uri, VLESS_URI);
const updatedVless = await jsonRequest(`${base}/mcp/${config.token}`, {
method: "POST",
headers: mcpHeaders,
body: {
jsonrpc: "2.0",
id: 5,
method: "tools/call",
params: {
name: "update_proxy",
arguments: {
proxy_id: vlessProxy.id,
name: "MCP VLESS Updated",
vless_uri: VLESS_URI,
},
},
},
});
assert.equal(updatedVless.value.error, undefined);
vlessProxy = (await app.invoke("get_stored_proxies")).find(
(proxy) => proxy.id === vlessProxy.id,
);
assert.equal(vlessProxy.name, "MCP VLESS Updated");
const invalidVless = await jsonRequest(`${base}/mcp/${config.token}`, {
method: "POST",
headers: mcpHeaders,
body: {
jsonrpc: "2.0",
id: 6,
method: "tools/call",
params: {
name: "update_proxy",
arguments: {
proxy_id: vlessProxy.id,
vless_uri: VLESS_URI.replace("security=reality", "security=tls"),
},
},
},
});
assert.match(invalidVless.value.error.message, /VLESS_CONFIG_INVALID/);
assert.equal(
(await app.invoke("get_stored_proxies")).find(
(proxy) => proxy.id === vlessProxy.id,
).proxy_settings.vless_uri,
VLESS_URI,
);
await app.invoke("delete_stored_proxy", { proxyId: vlessProxy.id });
const agents = await app.invoke("list_mcp_agents");
assert.ok(agents.some((agent) => agent.id === "cursor"));
await assertCommandErrorCode(app, "add_mcp_to_agent", "MCP_AGENT_UNKNOWN", {
+325 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readdir, readFile, writeFile } from "node:fs/promises";
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
import { isIP } from "node:net";
import path from "node:path";
import test from "node:test";
@@ -406,6 +406,330 @@ function wireGuardTargetWasReached() {
);
}
function processIsRunning(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
}
async function createXrayProfile(app, version) {
return app.invoke("create_browser_profile_new", {
name: "Xray Reality Profile",
browserStr: "wayfern",
version,
releaseType: "stable",
proxyId: null,
vpnId: null,
wayfernConfig: {
fingerprint: null,
randomize_fingerprint_on_launch: false,
geoip: false,
},
groupId: null,
ephemeral: false,
dnsBlocklist: null,
launchHook: null,
});
}
test("VLESS Reality persists, imports, routes through Xray-core, records traffic, and cleans up", async () => {
const vlessUri = process.env.DONUT_E2E_VLESS_URI;
const accessLog = process.env.DONUT_E2E_XRAY_ACCESS_LOG;
assert.ok(vlessUri, "The network harness must provide a VLESS Reality URI");
assert.ok(accessLog, "The network harness must provide an Xray access log");
const app = appFromEnvironment("network-xray-reality", {
seedVersionCache: false,
wayfernTermsAccepted: false,
});
let apiPort;
let activeCdp;
let profile;
let worker;
try {
const prepared = await prepareWayfern(
app,
process.env.DONUT_E2E_PROJECT_ROOT,
);
if (!app.session) await app.start();
if (!(await app.invoke("check_wayfern_terms_accepted"))) {
await app.invoke("accept_wayfern_terms");
await app.restart();
}
const invalidUri = vlessUri.replace("security=reality", "security=tls");
const invalidCreate = await app.invokeError("create_stored_proxy", {
name: "Invalid VLESS",
proxySettings: {
proxy_type: "vless",
host: "ignored.invalid",
port: 1,
username: "must-be-cleared",
password: "must-be-cleared",
vless_uri: invalidUri,
},
});
assert.match(invalidCreate, /VLESS_CONFIG_INVALID/);
const created = await app.invoke("create_stored_proxy", {
name: "Local VLESS Reality",
proxySettings: {
proxy_type: "VLESS",
host: "ignored.invalid",
port: 1,
username: "must-be-cleared",
password: "must-be-cleared",
vless_uri: vlessUri,
},
});
assert.equal(created.proxy_settings.proxy_type, "vless");
assert.equal(created.proxy_settings.host, "127.0.0.1");
assert.equal(created.proxy_settings.port, Number(new URL(vlessUri).port));
assert.equal(created.proxy_settings.username, null);
assert.equal(created.proxy_settings.password, null);
assert.equal(created.proxy_settings.vless_uri, vlessUri);
const updated = await app.invoke("update_stored_proxy", {
proxyId: created.id,
name: "Updated VLESS Reality",
proxySettings: {
proxy_type: "vless",
host: "still-ignored.invalid",
port: 2,
username: "still-cleared",
password: "still-cleared",
vless_uri: vlessUri,
},
});
assert.equal(updated.name, "Updated VLESS Reality");
assert.equal(updated.proxy_settings.host, "127.0.0.1");
assert.equal(updated.proxy_settings.username, null);
assert.equal(updated.proxy_settings.password, null);
const exportedJson = await app.invoke("export_proxies", {
format: "json",
});
const exported = JSON.parse(exportedJson);
assert.equal(exported.proxies.length, 1);
assert.deepEqual(exported.proxies[0], {
name: "Updated VLESS Reality",
type: "vless",
host: "127.0.0.1",
port: Number(new URL(vlessUri).port),
vless_uri: vlessUri,
});
assert.equal(
await app.invoke("export_proxies", { format: "txt" }),
vlessUri,
);
const parsed = await app.invoke("parse_txt_proxies", {
content: `${vlessUri}\n${invalidUri}\n`,
});
assert.equal(parsed.length, 2);
assert.equal(parsed[0].status, "parsed");
assert.equal(parsed[0].proxy_type, "vless");
assert.equal(parsed[0].vless_uri, vlessUri);
assert.equal(parsed[1].status, "invalid");
await app.restart();
const persisted = (await app.invoke("get_stored_proxies")).find(
(candidate) => candidate.id === created.id,
);
assert.ok(persisted, "VLESS proxy did not survive an app restart");
assert.equal(persisted.proxy_settings.vless_uri, vlessUri);
await app.invoke("delete_stored_proxy", { proxyId: created.id });
const imported = await app.invoke("import_proxies_json", {
content: exportedJson,
});
assert.equal(imported.imported_count, 1);
assert.equal(imported.skipped_count, 0);
assert.deepEqual(imported.errors, []);
assert.equal(imported.proxies[0].proxy_settings.vless_uri, vlessUri);
const proxy = imported.proxies[0];
const invalidImport = await app.invoke("import_proxies_json", {
content: JSON.stringify({
version: "1.0",
source: "DonutBrowser",
exported_at: new Date().toISOString(),
proxies: [
{
name: "Rejected VLESS",
type: "vless",
host: "127.0.0.1",
port: 443,
vless_uri: invalidUri,
},
],
}),
});
assert.equal(invalidImport.imported_count, 0);
assert.equal(invalidImport.errors.length, 1);
assert.match(invalidImport.errors[0], /VLESS_CONFIG_INVALID/);
profile = await createXrayProfile(app, prepared.version);
await app.invoke("update_profile_proxy", {
profileId: profile.id,
proxyId: proxy.id,
});
const assigned = (await app.invoke("list_browser_profiles")).find(
(candidate) => candidate.id === profile.id,
);
assert.equal(assigned.proxy_id, proxy.id);
const settings = await app.invoke("get_app_settings");
const saved = await app.invoke("save_app_settings", {
settings: {
...settings,
api_enabled: true,
api_port: 0,
api_token: null,
},
});
apiPort = await app.invoke("start_api_server", { port: 0 });
const base = `http://127.0.0.1:${apiPort}`;
const launched = await runProfile(
app,
base,
saved.api_token,
profile.id,
"https://api.ipify.org/",
);
activeCdp = launched.cdp;
const exitIp = await activeCdp.waitFor(
`(() => {
const value = document.body?.innerText?.trim() ?? "";
return /^[0-9a-f:.]+$/i.test(value) ? value : false;
})()`,
{ timeoutMs: 30_000, description: "VLESS Reality exit IP" },
);
assert.ok(isIP(exitIp), `Unexpected exit IP response: ${exitIp}`);
const workerDirectory = path.join(app.dataRoot, "cache", "proxy_workers");
await app.waitFor(
async () => {
const workerFiles = await readdir(workerDirectory).catch(() => []);
const workerFile = workerFiles.find(
(file) => file.startsWith("xray_worker_") && file.endsWith(".json"),
);
if (!workerFile) return false;
worker = JSON.parse(
await readFile(path.join(workerDirectory, workerFile), "utf8"),
);
return worker.profile_id === profile.id;
},
{ description: "profile-scoped Xray worker configuration" },
);
assert.ok(processIsRunning(worker.pid), "Xray supervisor is not running");
assert.ok(processIsRunning(worker.xray_pid), "Xray-core is not running");
assert.equal(worker.vless_uri, vlessUri);
const workerPath = path.join(
workerDirectory,
`xray_worker_${worker.id}.json`,
);
const runtimePath = path.join(
workerDirectory,
`xray_runtime_${worker.id}.json`,
);
if (process.platform !== "win32") {
assert.equal((await stat(workerPath)).mode & 0o777, 0o600);
assert.equal((await stat(runtimePath)).mode & 0o777, 0o600);
}
const runtime = JSON.parse(await readFile(runtimePath, "utf8"));
assert.equal(runtime.inbounds[0].protocol, "socks");
assert.equal(runtime.inbounds[0].listen, "127.0.0.1");
assert.equal(runtime.outbounds[0].protocol, "vless");
assert.equal(runtime.outbounds[0].streamSettings.security, "reality");
assert.equal(
runtime.outbounds[0].settings.vnext[0].users[0].flow,
"xtls-rprx-vision",
);
await app.waitFor(
async () =>
(await readFile(accessLog, "utf8").catch(() => "")).includes(
"api.ipify.org:443",
),
{
timeoutMs: 15_000,
description: "request in Xray-core server access log",
},
);
const liveTraffic = await app.waitFor(
async () => {
const snapshot = await app.invoke("get_profile_traffic_snapshot", {
profileId: profile.id,
});
return snapshot?.total_bytes_sent > 0 &&
snapshot?.total_bytes_received > 0
? snapshot
: false;
},
{
timeoutMs: 15_000,
description: "local traffic snapshot for VLESS profile",
},
);
assert.ok(liveTraffic.total_requests > 0);
const supervisorPid = worker.pid;
const xrayPid = worker.xray_pid;
await stopProfile(app, base, saved.api_token, profile.id, activeCdp);
activeCdp = null;
await app.waitFor(
async () => {
const files = await readdir(workerDirectory).catch(() => []);
return (
!files.includes(`xray_worker_${worker.id}.json`) &&
!processIsRunning(supervisorPid) &&
!processIsRunning(xrayPid)
);
},
{
timeoutMs: 15_000,
description: "Xray worker process and configuration cleanup",
},
);
await assert.rejects(readFile(runtimePath), { code: "ENOENT" });
const persistedTraffic = await app.invoke("get_profile_traffic_snapshot", {
profileId: profile.id,
});
assert.ok(
persistedTraffic.total_bytes_sent >= liveTraffic.total_bytes_sent,
);
assert.ok(
persistedTraffic.total_bytes_received >= liveTraffic.total_bytes_received,
);
} catch (error) {
await app.capture("failure");
throw error;
} finally {
activeCdp?.close();
if (app.session) {
if (apiPort) await app.invoke("stop_api_server").catch(() => {});
if (profile) {
const latest = (
await app.invoke("list_browser_profiles").catch(() => [])
).find((candidate) => candidate.id === profile.id);
if (latest?.process_id) {
await app
.invoke("kill_browser_profile", { profile: latest })
.catch(() => {});
}
}
}
await app.close();
}
});
test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions, and extension groups", async () => {
const httpSettings = proxySettings(
process.env.RESIDENTIAL_PROXY_URL_ONE_HTTP,
+446 -34
View File
@@ -1,5 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import Color from "color";
import { getDerivedThemeColors, THEMES } from "../../src/lib/themes.ts";
import { withApp } from "../lib/app.mjs";
const THEME_VARIABLES = [
@@ -31,34 +33,8 @@ const THEME_VARIABLES = [
"--chart-5",
];
const DRACULA_THEME = {
"--background": "#282a36",
"--foreground": "#f8f8f2",
"--card": "#44475a",
"--card-foreground": "#f8f8f2",
"--popover": "#44475a",
"--popover-foreground": "#f8f8f2",
"--primary": "#bd93f9",
"--primary-foreground": "#282a36",
"--secondary": "#8be9fd",
"--secondary-foreground": "#282a36",
"--muted": "#6272a4",
"--muted-foreground": "#f8f8f2",
"--accent": "#ff79c6",
"--accent-foreground": "#282a36",
"--destructive": "#ff5555",
"--destructive-foreground": "#f8f8f2",
"--success": "#50fa7b",
"--success-foreground": "#282a36",
"--warning": "#ffb86c",
"--warning-foreground": "#282a36",
"--border": "#6272a4",
"--chart-1": "#bd93f9",
"--chart-2": "#50fa7b",
"--chart-3": "#ff79c6",
"--chart-4": "#8be9fd",
"--chart-5": "#ffb86c",
};
const DRACULA_THEME = THEMES.find((theme) => theme.id === "dracula").colors;
const AYU_LIGHT_THEME = THEMES.find((theme) => theme.id === "ayu-light").colors;
async function dismissSurface(app) {
await app.pressShortcut({ key: "Escape" });
@@ -108,6 +84,90 @@ function themeVariablesEqual(actual, expected) {
);
}
async function applyThemeForContrastAudit(app, theme) {
await app.execute(
`
const [colors, derived, mode] = arguments;
const root = document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(mode);
for (const [key, value] of Object.entries({ ...colors, ...derived })) {
root.style.setProperty(key, value, "important");
}
`,
[theme.colors, getDerivedThemeColors(theme.colors), theme.mode],
);
await new Promise((resolve) => setTimeout(resolve, 200));
}
async function animatedTabContrastSnapshot(app) {
return app.execute(`
const rootStyle = getComputedStyle(document.documentElement);
const triggers = [
...document.querySelectorAll('[data-slot="animated-tabs-trigger"]'),
];
const active = triggers.find(
(trigger) => trigger.getAttribute("data-state") === "active",
);
const inactive = triggers.find(
(trigger) => trigger.getAttribute("data-state") === "inactive",
);
const content = (trigger) =>
[...(trigger?.children ?? [])].filter(
(child) =>
child.getAttribute("data-slot") !== "animated-tabs-indicator",
);
const activeContent = content(active);
const inactiveContent = content(inactive);
const indicator = active?.querySelector(
'[data-slot="animated-tabs-indicator"]',
);
return {
mode: document.documentElement.classList.contains("light")
? "light"
: "dark",
background: rootStyle.getPropertyValue("--background").trim(),
accent: rootStyle.getPropertyValue("--accent").trim(),
accentForeground: rootStyle
.getPropertyValue("--accent-foreground")
.trim(),
mutedForeground: rootStyle
.getPropertyValue("--muted-foreground")
.trim(),
activeTitle: activeContent[0]
? getComputedStyle(activeContent[0]).color
: null,
activeCount: activeContent[1]
? getComputedStyle(activeContent[1]).color
: null,
activeBackground: indicator
? getComputedStyle(indicator).backgroundColor
: null,
inactiveTitle: inactiveContent[0]
? getComputedStyle(inactiveContent[0]).color
: null,
inactiveCount: inactiveContent[1]
? getComputedStyle(inactiveContent[1]).color
: null,
};
`);
}
function assertColorEquals(actual, expected, description) {
assert.ok(actual, `${description} is missing`);
assert.equal(Color(actual).hex(), Color(expected).hex(), description);
}
function assertContrast(foreground, background, minimum, description) {
assert.ok(foreground, `${description} foreground is missing`);
assert.ok(background, `${description} background is missing`);
const ratio = Color(foreground).contrast(Color(background));
assert.ok(
ratio >= minimum,
`${description} is ${ratio.toFixed(2)}:1; expected at least ${minimum}:1`,
);
}
async function chooseSelectOption(app, triggerSelector, option) {
await app.clickSelector(triggerSelector);
await app.clickText(option, { roles: ["option"] });
@@ -204,13 +264,15 @@ async function dragBackgroundColorPicker(app) {
const pointerEvents = await app.execute(
`return window.__donutE2eThemePointerEvents ?? [];`,
);
assert.deepEqual(
pointerEvents.map((event) => event.type),
["pointermove", "pointerdown", "pointermove", "pointerup"],
);
assert.equal(pointerEvents[0]?.type, "pointermove");
assert.equal(pointerEvents[1]?.type, "pointerdown");
assert.equal(pointerEvents.at(-1)?.type, "pointerup");
const dragMoves = pointerEvents.slice(2, -1);
assert.ok(dragMoves.length >= 1);
assert.ok(dragMoves.every((event) => event.type === "pointermove"));
assert.match(pointerEvents[1].target, /cursor-pointer/);
assert.match(pointerEvents[2].target, /cursor-pointer/);
assert.equal(pointerEvents[2].buttons, 1);
assert.match(dragMoves.at(-1).target, /cursor-pointer/);
assert.equal(dragMoves.at(-1).buttons, 1);
await app.waitFor(
() =>
app.execute(
@@ -274,6 +336,325 @@ test("all primary navigation buttons and sub-page tabs render and remain interac
});
});
test("every custom theme keeps tabs, counts, group pills, and rail states readable", async () => {
await withApp("ui-theme-contrast", async (app) => {
await app.clickSelector('[aria-label="Extensions"]');
await app.waitFor(
() =>
app.execute(
`return document.querySelectorAll('[data-slot="animated-tabs-trigger"]').length === 2;`,
),
{ description: "Extension tabs" },
);
for (const theme of THEMES) {
await applyThemeForContrastAudit(app, theme);
const snapshot = await animatedTabContrastSnapshot(app);
assert.equal(snapshot.mode, theme.mode, `${theme.id} appearance mode`);
assertColorEquals(
snapshot.activeBackground,
snapshot.accent,
`${theme.id} active tab background`,
);
for (const [label, color] of [
["active tab title", snapshot.activeTitle],
["active tab count", snapshot.activeCount],
]) {
assertColorEquals(
color,
snapshot.accentForeground,
`${theme.id} ${label} token`,
);
assertContrast(
color,
snapshot.activeBackground,
4.5,
`${theme.id} ${label}`,
);
}
for (const [label, color] of [
["inactive tab title", snapshot.inactiveTitle],
["inactive tab count", snapshot.inactiveCount],
]) {
assertColorEquals(
color,
snapshot.mutedForeground,
`${theme.id} ${label} token`,
);
assertContrast(color, snapshot.background, 4.5, `${theme.id} ${label}`);
}
await app.clickSelector(
'[data-slot="animated-tabs-trigger"][data-state="inactive"]',
);
await app.waitFor(
() =>
app.execute(
`return Boolean(document.querySelector(
'[data-slot="animated-tabs-trigger"][data-state="active"] [data-slot="animated-tabs-indicator"]'
));`,
),
{ description: `${theme.id} tab indicator after switching` },
);
}
await app.clickSelector('[aria-label="Groups"]');
await app.waitFor(
() =>
app.execute(
`return Boolean(document.querySelector('[data-slot="group-summary-pill"]'));`,
),
{ description: "Profile groups summary pill" },
);
for (const theme of THEMES) {
await applyThemeForContrastAudit(app, theme);
const snapshot = await app.execute(`
const rootStyle = getComputedStyle(document.documentElement);
const pill = document.querySelector(
'[data-slot="group-summary-pill"]',
);
const count = pill?.querySelector(
'[data-slot="group-summary-count"]',
);
const title = pill?.firstElementChild;
const rail = document.querySelector('nav [aria-current="page"]');
return {
pillBackground: pill ? getComputedStyle(pill).backgroundColor : null,
pillTitle: title ? getComputedStyle(title).color : null,
pillCount: count ? getComputedStyle(count).color : null,
railBackground: rail
? getComputedStyle(rail).backgroundColor
: null,
railForeground: rail ? getComputedStyle(rail).color : null,
accent: rootStyle.getPropertyValue("--accent").trim(),
accentForeground: rootStyle
.getPropertyValue("--accent-foreground")
.trim(),
};
`);
assertColorEquals(
snapshot.pillBackground,
snapshot.accent,
`${theme.id} group pill background`,
);
for (const [label, color] of [
["group pill title", snapshot.pillTitle],
["group pill count", snapshot.pillCount],
]) {
assertColorEquals(
color,
snapshot.accentForeground,
`${theme.id} ${label} token`,
);
assertContrast(
color,
snapshot.pillBackground,
4.5,
`${theme.id} ${label}`,
);
}
assertContrast(
snapshot.railForeground,
snapshot.railBackground,
3,
`${theme.id} selected rail icon`,
);
}
});
});
test("VLESS proxy form keeps the share URI as one clear, validated input", async () => {
await withApp("ui-vless-proxy-form", async (app) => {
const uri =
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.example.com&fp=chrome&pbk=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc&sid=0123456789abcdef&type=tcp#E2E";
await app.clickSelector('[aria-label="Network"]');
await app.waitForText("New proxy");
await app.clickSelector('[aria-label="New proxy"]');
await app.waitForText("Add Proxy");
await app.fillSelector("#proxy-name", "E2E VLESS");
await chooseSelectOption(app, "#proxy-type", "VLESS · Vision · REALITY");
assert.equal(
await app.execute(
`return document.querySelector("#proxy-vless-uri") instanceof HTMLTextAreaElement;`,
),
true,
);
assert.equal(
await app.execute(
`return document.querySelector("#proxy-host") === null &&
document.querySelector("#proxy-port") === null &&
document.querySelector("#proxy-username") === null &&
document.querySelector("#proxy-password") === null;`,
),
true,
);
await app.fillSelector(
"#proxy-vless-uri",
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com",
);
await app.waitFor(
() =>
app.execute(
`return document.querySelector("#proxy-vless-uri")?.getAttribute("aria-invalid") === "true";`,
),
{ description: "invalid VLESS endpoint feedback" },
);
assert.equal(
await app.execute(
`return [...document.querySelectorAll("[role='dialog'] button")]
.find((button) => button.textContent?.trim() === "Add Proxy")
?.disabled === true;`,
),
true,
);
await app.fillSelector("#proxy-vless-uri", uri);
await app.waitFor(
() =>
app.execute(
`return document.querySelector("#proxy-vless-uri")?.getAttribute("aria-invalid") === "false";`,
),
{ description: "valid VLESS endpoint feedback" },
);
await app.clickTextIn('[role="dialog"]', "Add Proxy", {
roles: ["button"],
});
await app.waitForText("E2E VLESS");
const proxy = (await app.invoke("get_stored_proxies")).find(
(item) => item.name === "E2E VLESS",
);
assert.ok(proxy);
assert.equal(proxy.proxy_settings.proxy_type, "vless");
assert.equal(proxy.proxy_settings.host, "vpn.example.com");
assert.equal(proxy.proxy_settings.port, 443);
assert.equal(proxy.proxy_settings.username, null);
assert.equal(proxy.proxy_settings.password, null);
assert.match(proxy.proxy_settings.vless_uri, /^vless:\/\//);
await app.invoke("delete_stored_proxy", { proxyId: proxy.id });
});
});
test("About exposes a searchable, responsive third-party license inventory", async () => {
await withApp("ui-about-licenses", async (app) => {
await app.clickSelector('[aria-label="More"]');
await app.waitFor(
() =>
app.execute(`return Boolean(document.querySelector("[role='menu']"));`),
{ description: "More menu" },
);
await app.clickText("About Donut Browser", {
exact: false,
roles: ["menuitem"],
});
await app.waitForText("Open-source anti-detect browser.");
const aboutText = await app.execute(
`return document.querySelector("[role='dialog']")?.textContent ?? "";`,
);
assert.doesNotMatch(aboutText, /AGPL-3\.0|licensed under/i);
await app.clickText("Licenses", { roles: ["button"] });
await app.waitFor(
() =>
app.execute(
`return document.querySelector("[role='dialog'] [data-slot='dialog-title']")?.textContent === "Licenses";`,
),
{ description: "Licenses view" },
);
await app.session.command("POST", "/window/rect", {
width: 640,
height: 400,
});
const inventory = await app.execute(`
const dialog = document.querySelector("[role='dialog']");
const list = dialog?.querySelector(".scroll-fade");
const search = dialog?.querySelector('input[type="search"]');
const rows = [...(dialog?.querySelectorAll("li") ?? [])].map((row) =>
[...row.children].map((child) => (child.textContent || "").trim())
);
const rect = dialog?.getBoundingClientRect();
return {
activeSearch: document.activeElement === search,
rows,
scrollable: Boolean(list && list.scrollHeight > list.clientHeight),
bounds: rect
? {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
viewportWidth: innerWidth,
viewportHeight: innerHeight,
}
: null,
};
`);
assert.equal(inventory.activeSearch, true);
assert.equal(inventory.scrollable, true);
assert.ok(inventory.bounds);
assert.ok(inventory.bounds.left >= 0);
assert.ok(inventory.bounds.top >= 0);
assert.ok(inventory.bounds.right <= inventory.bounds.viewportWidth);
assert.ok(inventory.bounds.bottom <= inventory.bounds.viewportHeight);
assert.ok(
inventory.rows.some(
([name, license]) => name === "Xray-core" && license === "MPL-2.0",
),
);
assert.ok(
inventory.rows.some(
([name, license]) =>
name === "Donut Browser" && license === "AGPL-3.0-only",
),
);
assert.ok(
inventory.rows.some(
([name, license]) =>
name === "tauri-plugin-opener" && license === "Apache-2.0 OR MIT",
),
);
assert.ok(inventory.rows.every((row) => row.length === 2));
const search = await app.session.findCss('input[type="search"]');
await app.session.sendKeys(search, "xray");
await app.waitFor(
() =>
app.execute(
`return document.querySelectorAll("[role='dialog'] li").length === 1;`,
),
{ description: "license search result" },
);
assert.match(
await app.execute(
`return document.querySelector("[role='dialog'] li")?.textContent ?? "";`,
),
/Xray-core.*MPL-2\.0/s,
);
await app.clickSelector('button[aria-label="Back"]');
await app.waitFor(
() =>
app.execute(
`return document.querySelector("[role='dialog'] [data-slot='dialog-title']")?.textContent === "About";`,
),
{ description: "About view after returning from licenses" },
);
assert.equal(
await app.execute(
`return document.activeElement?.textContent?.trim() === "Licenses";`,
),
true,
);
});
});
test("settings tabs, command palette filtering, and responsive layout survive resize", async () => {
await withApp("ui-settings-responsive", async (app) => {
await app.clickSelector('[aria-label="Settings"]');
@@ -503,3 +884,34 @@ test("preset and manually customized themes survive navigation and restart", asy
);
});
});
test("a light custom preset keeps light component behavior after restart", async () => {
await withApp("ui-theme-custom-light", async (app) => {
await app.clickSelector('[aria-label="Settings"]');
await app.waitForText("Appearance");
await chooseSelectOption(app, "#theme-select", "Custom");
await chooseSelectOption(app, "#theme-preset-select", "Ayu Light");
await saveSettings(app);
const selected = await waitForTheme(
app,
(snapshot) =>
snapshot.mode === "light" &&
themeVariablesEqual(snapshot.inline, AYU_LIGHT_THEME) &&
themeVariablesEqual(snapshot.resolved, AYU_LIGHT_THEME),
"Ayu Light variables and light mode to render",
);
await assertThemeAcrossNavigation(app, selected);
await app.restart();
assert.deepEqual(
(await app.invoke("get_app_settings")).custom_theme,
AYU_LIGHT_THEME,
);
await app.waitFor(
async () =>
JSON.stringify(await themeSnapshot(app)) === JSON.stringify(selected),
{ description: "Ayu Light preset after restart" },
);
});
});
+10 -2
View File
@@ -5,10 +5,17 @@
"version": "0.28.2",
"type": "module",
"scripts": {
"predev": "pnpm licenses:generate",
"dev": "next dev --turbopack -p 12341",
"prebuild": "pnpm licenses:generate",
"build": "next build",
"start": "next start",
"test": "pnpm test:rust:unit && pnpm test:sync-e2e",
"test": "pnpm test:themes && 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: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",
"licenses:check": "node scripts/generate-licenses.mjs --check",
"test:rust": "cd src-tauri && cargo test",
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration",
"test:sync-e2e": "node scripts/sync-test-harness.mjs",
@@ -21,7 +28,7 @@
"e2e:sync": "node e2e/run.mjs --suite=sync",
"e2e:browser": "node e2e/run.mjs --suite=browser",
"lint": "pnpm lint:js && pnpm lint:rust && pnpm lint:spell",
"lint:js": "biome check src/ e2e/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
"lint:js": "biome check src/ e2e/ scripts/generate-licenses.mjs scripts/generate-licenses.test.mjs src-tauri/download-xray.mjs src-tauri/download-xray.test.mjs src-tauri/copy-proxy-binary.mjs && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"lint:spell": "typos .",
"tauri": "node scripts/run-with-env.mjs tauri",
@@ -96,6 +103,7 @@
"@types/react-dom": "^19.2.3",
"husky": "^9.1.7",
"lint-staged": "^17.0.8",
"spdx-expression-parse": "5.0.0",
"tailwindcss": "^4.3.2",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
+21
View File
@@ -192,6 +192,9 @@ importers:
lint-staged:
specifier: ^17.0.8
version: 17.0.8
spdx-expression-parse:
specifier: 5.0.0
version: 5.0.0
tailwindcss:
specifier: ^4.3.2
version: 4.3.2
@@ -4611,6 +4614,15 @@ packages:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
spdx-exceptions@2.5.0:
resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==}
spdx-expression-parse@5.0.0:
resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==}
spdx-license-ids@3.0.23:
resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==}
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
@@ -9943,6 +9955,15 @@ snapshots:
source-map@0.7.6: {}
spdx-exceptions@2.5.0: {}
spdx-expression-parse@5.0.0:
dependencies:
spdx-exceptions: 2.5.0
spdx-license-ids: 3.0.23
spdx-license-ids@3.0.23: {}
sprintf-js@1.0.3: {}
stack-utils@2.0.6:
+229
View File
@@ -0,0 +1,229 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import parseSpdxExpression from "spdx-expression-parse";
import { XRAY_SOURCE_URL } from "../src-tauri/download-xray.mjs";
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(SCRIPT_DIR, "..");
const OUTPUT_PATH = resolve(PROJECT_ROOT, "src/generated/licenses.json");
const XRAY_SOURCE_OUTPUT_PATH = resolve(
PROJECT_ROOT,
"src/generated/xray-source.json",
);
const MAX_COMMAND_OUTPUT = 64 * 1024 * 1024;
export const RELEASE_TARGETS = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
];
export const MANUAL_LICENSES = [
{
name: "Donut Browser",
license: "AGPL-3.0-only",
},
{
name: "Xray-core",
license: "MPL-2.0",
},
];
const LEGACY_LICENSE_EXPRESSIONS = new Map([
["Apache-2.0 / MIT", "Apache-2.0 OR MIT"],
["Apache-2.0/MIT", "Apache-2.0 OR MIT"],
["BSD-3-Clause/MIT", "BSD-3-Clause OR MIT"],
["MIT/Apache-2.0", "Apache-2.0 OR MIT"],
["MIT OR Apache-2.0", "Apache-2.0 OR MIT"],
["Unlicense/MIT", "MIT OR Unlicense"],
]);
const HOST_ONLY_PNPM_NATIVE_PREFIXES = [
"@img/sharp-",
"@img/sharp-libvips-",
"@next/swc-",
];
function validateLicenseExpression(expression) {
try {
parseSpdxExpression(expression);
} catch {
throw new Error(`Invalid SPDX expression: ${expression}`);
}
}
export function normalizeLicenseExpression(value) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error("Every shipped dependency must declare a license");
}
const expression =
LEGACY_LICENSE_EXPRESSIONS.get(value.trim()) ?? value.trim();
validateLicenseExpression(expression);
return expression;
}
export function collectReachableRustLicenses(metadata) {
const root = metadata.resolve?.root;
if (!root) {
throw new Error("Cargo metadata did not identify the root package");
}
const packages = new Map(
metadata.packages.map((dependency) => [dependency.id, dependency]),
);
const nodes = new Map(metadata.resolve.nodes.map((node) => [node.id, node]));
const pending = [root];
const visited = new Set();
const result = [];
while (pending.length > 0) {
const packageId = pending.pop();
if (!packageId || visited.has(packageId)) continue;
visited.add(packageId);
if (packageId !== root) {
const dependency = packages.get(packageId);
if (!dependency) {
throw new Error(`Cargo metadata is missing package ${packageId}`);
}
result.push({
name: dependency.name,
license: dependency.license,
});
}
const node = nodes.get(packageId);
if (!node) continue;
for (const dependency of node.deps) {
const isRuntimeDependency = dependency.dep_kinds.some(
({ kind }) => kind === null,
);
if (isRuntimeDependency) pending.push(dependency.pkg);
}
}
return result;
}
export function collectPnpmLicenses(report) {
return Object.entries(report).flatMap(([groupLicense, dependencies]) =>
dependencies
.filter(
(dependency) =>
!HOST_ONLY_PNPM_NATIVE_PREFIXES.some((prefix) =>
dependency.name.startsWith(prefix),
),
)
.map((dependency) => ({
name: dependency.name,
license: dependency.license ?? groupLicense,
})),
);
}
export function prepareLicenseInventory(entries) {
const unique = new Map();
for (const entry of entries) {
if (typeof entry.name !== "string" || entry.name.trim() === "") {
throw new Error("Every shipped dependency must have a name");
}
const name = entry.name.trim();
const license = normalizeLicenseExpression(entry.license);
unique.set(`${name}\0${license}`, { name, license });
}
return [...unique.values()].sort((left, right) => {
const leftName = left.name.toLowerCase();
const rightName = right.name.toLowerCase();
if (leftName < rightName) return -1;
if (leftName > rightName) return 1;
if (left.name < right.name) return -1;
if (left.name > right.name) return 1;
return left.license < right.license
? -1
: Number(left.license > right.license);
});
}
function commandOutput(command, args) {
const executable =
process.platform === "win32" && command === "pnpm" ? "pnpm.cmd" : command;
return execFileSync(executable, args, {
cwd: PROJECT_ROOT,
encoding: "utf8",
maxBuffer: MAX_COMMAND_OUTPUT,
});
}
function generateInventory() {
const entries = [...MANUAL_LICENSES];
const pnpmReport = JSON.parse(
commandOutput("pnpm", [
"--filter",
"donutbrowser",
"licenses",
"list",
"--prod",
"--json",
]),
);
entries.push(...collectPnpmLicenses(pnpmReport));
for (const target of RELEASE_TARGETS) {
const metadata = JSON.parse(
commandOutput("cargo", [
"metadata",
"--locked",
"--format-version",
"1",
"--filter-platform",
target,
"--manifest-path",
"src-tauri/Cargo.toml",
]),
);
entries.push(...collectReachableRustLicenses(metadata));
}
return prepareLicenseInventory(entries);
}
function main() {
const outputs = [
{
path: OUTPUT_PATH,
contents: `${JSON.stringify(generateInventory(), null, 2)}\n`,
},
{
path: XRAY_SOURCE_OUTPUT_PATH,
contents: `${JSON.stringify({ sourceUrl: XRAY_SOURCE_URL }, null, 2)}\n`,
},
];
if (process.argv.includes("--check")) {
for (const output of outputs) {
const current = readFileSync(output.path, "utf8");
if (current !== output.contents) {
throw new Error(`${output.path} is stale; run pnpm licenses:generate`);
}
}
return;
}
for (const output of outputs) {
mkdirSync(dirname(output.path), { recursive: true });
writeFileSync(output.path, output.contents);
}
}
const isDirectRun =
process.argv[1] &&
fileURLToPath(import.meta.url) === resolve(process.argv[1]);
if (isDirectRun) main();
+182
View File
@@ -0,0 +1,182 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { XRAY_SOURCE_URL } from "../src-tauri/download-xray.mjs";
import {
collectPnpmLicenses,
collectReachableRustLicenses,
normalizeLicenseExpression,
prepareLicenseInventory,
} from "./generate-licenses.mjs";
test("normalizes legacy dual-license metadata into SPDX expressions", () => {
assert.equal(
normalizeLicenseExpression("MIT/Apache-2.0"),
"Apache-2.0 OR MIT",
);
assert.equal(
normalizeLicenseExpression("Apache-2.0 OR MIT"),
"Apache-2.0 OR MIT",
);
assert.throws(() => normalizeLicenseExpression(""), /declare a license/);
assert.throws(
() => normalizeLicenseExpression("not/a/license"),
/Invalid SPDX expression/,
);
for (const invalid of [
"NOASSERTION",
"Definitely-Not-A-License",
"MPL-999.0",
]) {
assert.throws(
() => normalizeLicenseExpression(invalid),
/Invalid SPDX expression/,
);
}
});
test("collects only normal Rust dependencies reachable from the app", () => {
const metadata = {
packages: [
{ id: "app", name: "app", license: "AGPL-3.0" },
{ id: "runtime", name: "runtime", license: "MIT" },
{ id: "nested", name: "nested", license: "Apache-2.0" },
{ id: "build", name: "build", license: "MIT" },
{ id: "dev", name: "dev", license: "MIT" },
],
resolve: {
root: "app",
nodes: [
{
id: "app",
deps: [
{ pkg: "runtime", dep_kinds: [{ kind: null }] },
{ pkg: "build", dep_kinds: [{ kind: "build" }] },
{ pkg: "dev", dep_kinds: [{ kind: "dev" }] },
],
},
{
id: "runtime",
deps: [{ pkg: "nested", dep_kinds: [{ kind: null }] }],
},
{ id: "nested", deps: [] },
{ id: "build", deps: [] },
{ id: "dev", deps: [] },
],
},
};
assert.deepEqual(collectReachableRustLicenses(metadata), [
{ name: "runtime", license: "MIT" },
{ name: "nested", license: "Apache-2.0" },
]);
});
test("flattens pnpm groups and emits a stable name-and-license-only list", () => {
const pnpmEntries = collectPnpmLicenses({
MIT: [
{
name: "zeta",
license: "MIT",
versions: ["1.2.3"],
author: "Not included",
},
{ name: "@next/swc-darwin-arm64", license: "MIT" },
{ name: "@next/swc-linux-x64-gnu", license: "MIT" },
],
"Apache-2.0": [
{ name: "@img/sharp-darwin-arm64" },
{ name: "@img/sharp-linux-x64" },
],
"LGPL-3.0-or-later": [
{ name: "@img/sharp-libvips-darwin-arm64" },
{ name: "@img/sharp-libvips-linux-x64" },
],
"MIT OR Apache-2.0": [{ name: "alpha" }],
});
const inventory = prepareLicenseInventory([
...pnpmEntries,
{ name: "zeta", license: "MIT", copyright: "Not included" },
]);
assert.deepEqual(inventory, [
{ name: "alpha", license: "Apache-2.0 OR MIT" },
{ name: "zeta", license: "MIT" },
]);
assert.deepEqual(Object.keys(inventory[0]).sort(), ["license", "name"]);
});
test("pnpm inventory is stable across host-native build packages", () => {
const reportForHost = (swc, sharp, libvips) => ({
MIT: [
{ name: "shared-runtime" },
{ name: swc },
{ name: sharp, license: "Apache-2.0" },
{ name: libvips, license: "LGPL-3.0-or-later" },
],
});
const darwin = collectPnpmLicenses(
reportForHost(
"@next/swc-darwin-arm64",
"@img/sharp-darwin-arm64",
"@img/sharp-libvips-darwin-arm64",
),
);
const linux = collectPnpmLicenses(
reportForHost(
"@next/swc-linux-x64-gnu",
"@img/sharp-linux-x64",
"@img/sharp-libvips-linux-x64",
),
);
assert.deepEqual(darwin, linux);
assert.deepEqual(darwin, [{ name: "shared-runtime", license: "MIT" }]);
});
test("generated inventory includes the bundled sidecar and Tauri opener", async () => {
const inventory = JSON.parse(
await readFile(
new URL("../src/generated/licenses.json", import.meta.url),
"utf8",
),
);
assert.ok(
inventory.some(
(entry) =>
entry.name === "Donut Browser" && entry.license === "AGPL-3.0-only",
),
);
assert.ok(
inventory.some(
(entry) => entry.name === "Xray-core" && entry.license === "MPL-2.0",
),
);
assert.ok(
inventory.some(
(entry) =>
entry.name === "tauri-plugin-opener" &&
entry.license === "Apache-2.0 OR MIT",
),
);
assert.ok(
inventory.every(
(entry) =>
Object.keys(entry).length === 2 &&
typeof entry.name === "string" &&
typeof entry.license === "string",
),
);
});
test("generated Xray source link matches the packaged release", async () => {
const source = JSON.parse(
await readFile(
new URL("../src/generated/xray-source.json", import.meta.url),
"utf8",
),
);
assert.deepEqual(source, { sourceUrl: XRAY_SOURCE_URL });
});
+8 -1
View File
@@ -1,5 +1,7 @@
fn main() {
println!("cargo::rustc-check-cfg=cfg(mobile)");
let build_target = std::env::var("TARGET").expect("Cargo must provide TARGET");
println!("cargo:rustc-env=DONUT_BUILD_TARGET={build_target}");
// Ensure dist folder exists for tauri::generate_context!() macro
// This allows running cargo test without building the frontend first
@@ -98,8 +100,13 @@ fn external_binaries_exist() -> bool {
} else {
format!("donut-proxy-{}", target)
};
let xray_name = if target.contains("windows") {
format!("xray-{}.exe", target)
} else {
format!("xray-{}", target)
};
binaries_dir.join(&donut_proxy_name).exists()
binaries_dir.join(&donut_proxy_name).exists() && binaries_dir.join(&xray_name).exists()
}
fn ensure_dist_folder_exists() {
+15 -4
View File
@@ -1,7 +1,8 @@
import { execSync, execFileSync } from "node:child_process";
import { execFileSync, execSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { downloadXray } from "./download-xray.mjs";
const MANIFEST_DIR = dirname(fileURLToPath(import.meta.url));
const PROFILE =
@@ -35,9 +36,18 @@ const isWindows = TARGET.includes("windows");
// Determine source directory
let srcDir;
if (TARGET === HOST_TARGET || TARGET === "unknown") {
srcDir = join(MANIFEST_DIR, "target", PROFILE === "release" ? "release" : "debug");
srcDir = join(
MANIFEST_DIR,
"target",
PROFILE === "release" ? "release" : "debug",
);
} else {
srcDir = join(MANIFEST_DIR, "target", TARGET, PROFILE === "release" ? "release" : "debug");
srcDir = join(
MANIFEST_DIR,
"target",
TARGET,
PROFILE === "release" ? "release" : "debug",
);
}
const destDir = join(MANIFEST_DIR, "binaries");
@@ -70,3 +80,4 @@ function copyBinary(baseName) {
}
copyBinary("donut-proxy");
await downloadXray(TARGET);
+200
View File
@@ -0,0 +1,200 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
export const XRAY_VERSION = "v26.3.27";
export const XRAY_SOURCE_URL = `https://github.com/XTLS/Xray-core/tree/${XRAY_VERSION}`;
export const XRAY_LICENSE_FILE = "xray-LICENSE.txt";
export const XRAY_ASSETS = {
"aarch64-apple-darwin": {
name: "Xray-macos-arm64-v8a.zip",
sha256: "2e93a67e8aa1936ecefb307e120830fcbd4c643ab9b1c46a2d0838d5f8409eaf",
},
"x86_64-apple-darwin": {
name: "Xray-macos-64.zip",
sha256: "f5b0471d3459eff1b82e48af0aeac186abcc3298210070afbbbd8437a4e8b203",
},
"x86_64-unknown-linux-gnu": {
name: "Xray-linux-64.zip",
sha256: "23cd9af937744d97776ee35ecad4972cf4b2109d1e0fe6be9930467608f7c8ae",
},
"aarch64-unknown-linux-gnu": {
name: "Xray-linux-arm64-v8a.zip",
sha256: "4d30283ae614e3057f730f67cd088a42be6fdf91f8639d82cb69e48cde80413c",
},
"x86_64-pc-windows-msvc": {
name: "Xray-windows-64.zip",
sha256: "d004c39288ce9ada487c6f398c7c545f7d749e44bdfdd59dbc9f865afba4e1ad",
},
};
const MANIFEST_DIR = dirname(fileURLToPath(import.meta.url));
export function requestedTarget() {
const targetIndex = process.argv.indexOf("--target");
if (targetIndex !== -1 && process.argv[targetIndex + 1]) {
return process.argv[targetIndex + 1];
}
if (process.env.TARGET) {
return process.env.TARGET;
}
const result = spawnSync("rustc", ["-vV"], { encoding: "utf8" });
const match = result.stdout?.match(/^host:\s*(.+)$/m);
if (!match) {
throw new Error("Unable to determine the Rust target");
}
return match[1].trim();
}
function sha256(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}
export function xrayBinaryName(target) {
return `xray-${target}${target.includes("windows") ? ".exe" : ""}`;
}
export function xrayDownloadUrl(assetName) {
return `https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/${assetName}`;
}
function extractArchive(archive, destinationDir, windowsTarget) {
if (windowsTarget) {
const result = spawnSync(
"powershell",
[
"-NoProfile",
"-NonInteractive",
"-Command",
"Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force",
archive,
destinationDir,
],
{ stdio: "inherit" },
);
if (result.status !== 0) {
throw new Error("Failed to extract the Xray-core archive");
}
return {
binary: join(destinationDir, "Xray.exe"),
license: join(destinationDir, "LICENSE"),
};
}
const result = spawnSync(
"unzip",
["-qq", "-j", archive, "xray", "LICENSE", "-d", destinationDir],
{ stdio: "inherit" },
);
if (result.status !== 0) {
throw new Error("Failed to extract the Xray-core archive");
}
return {
binary: join(destinationDir, "xray"),
license: join(destinationDir, "LICENSE"),
};
}
export async function downloadXray(target = requestedTarget()) {
const asset = XRAY_ASSETS[target];
if (!asset) {
throw new Error(`Xray-core is not packaged for Rust target '${target}'`);
}
const windowsTarget = target.includes("windows");
const destinationDir = join(MANIFEST_DIR, "binaries");
const destination = join(destinationDir, xrayBinaryName(target));
const licenseDestination = join(destinationDir, XRAY_LICENSE_FILE);
const marker = `${destination}.source.json`;
if (
existsSync(destination) &&
existsSync(licenseDestination) &&
existsSync(marker)
) {
try {
const source = JSON.parse(readFileSync(marker, "utf8"));
if (
source.version === XRAY_VERSION &&
source.archiveSha256 === asset.sha256 &&
source.binarySha256 === sha256(destination) &&
source.licenseSha256 === sha256(licenseDestination)
) {
return destination;
}
} catch {
// A partial or older cache entry is replaced from the verified archive.
}
}
mkdirSync(destinationDir, { recursive: true });
const scratch = mkdtempSync(join(tmpdir(), "donut-xray-"));
try {
const archive = join(scratch, basename(asset.name));
const response = await fetch(xrayDownloadUrl(asset.name));
if (!response.ok) {
throw new Error(
`Failed to download Xray-core (${response.status} ${response.statusText})`,
);
}
writeFileSync(archive, Buffer.from(await response.arrayBuffer()));
const actual = sha256(archive);
if (actual !== asset.sha256) {
throw new Error(
`Xray-core checksum mismatch: expected ${asset.sha256}, got ${actual}`,
);
}
const extracted = extractArchive(archive, scratch, windowsTarget);
if (!existsSync(extracted.binary) || !existsSync(extracted.license)) {
throw new Error(
"The Xray-core archive did not contain its executable and license",
);
}
copyFileSync(extracted.binary, destination);
copyFileSync(extracted.license, licenseDestination);
if (!windowsTarget) {
chmodSync(destination, 0o755);
}
writeFileSync(
marker,
`${JSON.stringify(
{
version: XRAY_VERSION,
archiveSha256: asset.sha256,
binarySha256: sha256(destination),
licenseSha256: sha256(licenseDestination),
},
null,
2,
)}\n`,
);
console.log(`Downloaded Xray-core ${XRAY_VERSION} to ${destination}`);
return destination;
} finally {
rmSync(scratch, { recursive: true, force: true });
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
downloadXray().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
}
+100
View File
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import {
downloadXray,
XRAY_ASSETS,
XRAY_LICENSE_FILE,
XRAY_SOURCE_URL,
XRAY_VERSION,
xrayBinaryName,
xrayDownloadUrl,
} from "./download-xray.mjs";
const EXPECTED_ASSETS = {
"aarch64-apple-darwin": [
"Xray-macos-arm64-v8a.zip",
"2e93a67e8aa1936ecefb307e120830fcbd4c643ab9b1c46a2d0838d5f8409eaf",
],
"x86_64-apple-darwin": [
"Xray-macos-64.zip",
"f5b0471d3459eff1b82e48af0aeac186abcc3298210070afbbbd8437a4e8b203",
],
"x86_64-unknown-linux-gnu": [
"Xray-linux-64.zip",
"23cd9af937744d97776ee35ecad4972cf4b2109d1e0fe6be9930467608f7c8ae",
],
"aarch64-unknown-linux-gnu": [
"Xray-linux-arm64-v8a.zip",
"4d30283ae614e3057f730f67cd088a42be6fdf91f8639d82cb69e48cde80413c",
],
"x86_64-pc-windows-msvc": [
"Xray-windows-64.zip",
"d004c39288ce9ada487c6f398c7c545f7d749e44bdfdd59dbc9f865afba4e1ad",
],
};
test("pins the official Xray-core release and supported assets", () => {
assert.equal(XRAY_VERSION, "v26.3.27");
assert.equal(
XRAY_SOURCE_URL,
"https://github.com/XTLS/Xray-core/tree/v26.3.27",
);
assert.deepEqual(
Object.fromEntries(
Object.entries(XRAY_ASSETS).map(([target, asset]) => [
target,
[asset.name, asset.sha256],
]),
),
EXPECTED_ASSETS,
);
for (const asset of Object.values(XRAY_ASSETS)) {
assert.match(asset.sha256, /^[a-f0-9]{64}$/);
assert.equal(
xrayDownloadUrl(asset.name),
`https://github.com/XTLS/Xray-core/releases/download/v26.3.27/${asset.name}`,
);
}
});
test("uses the sidecar filenames expected by Tauri", () => {
assert.equal(XRAY_LICENSE_FILE, "xray-LICENSE.txt");
assert.equal(
xrayBinaryName("aarch64-apple-darwin"),
"xray-aarch64-apple-darwin",
);
assert.equal(
xrayBinaryName("x86_64-pc-windows-msvc"),
"xray-x86_64-pc-windows-msvc.exe",
);
});
test("bundles the upstream license in Tauri and portable releases", async () => {
const tauriConfig = JSON.parse(
await readFile(new URL("./tauri.conf.json", import.meta.url), "utf8"),
);
assert.equal(
tauriConfig.bundle.resources[`binaries/${XRAY_LICENSE_FILE}`],
"licenses/Xray-core-LICENSE.txt",
);
for (const workflow of ["release.yml", "rolling-release.yml"]) {
const contents = await readFile(
new URL(`../.github/workflows/${workflow}`, import.meta.url),
"utf8",
);
assert.match(
contents,
/cp "src-tauri\/binaries\/xray-LICENSE\.txt" "\$PORTABLE_DIR\/licenses\/Xray-core-LICENSE\.txt"/,
);
}
});
test("rejects an unsupported target before downloading", async () => {
await assert.rejects(
downloadXray("riscv64gc-unknown-linux-gnu"),
/not packaged for Rust target/,
);
});
+12
View File
@@ -2772,6 +2772,18 @@ mod tests {
"proxy_settings must be optional on update, required list: {update_proxy:?}"
);
let proxy_settings = schema_required(&spec, "ProxySettings");
for field in ["username", "password", "vless_uri"] {
assert!(
!proxy_settings.iter().any(|candidate| candidate == field),
"{field} must be optional in proxy settings, required list: {proxy_settings:?}"
);
assert!(
spec["components"]["schemas"]["ProxySettings"]["properties"][field].is_object(),
"{field} must be present in the served ProxySettings schema"
);
}
let import_profiles = schema_required(&spec, "ImportProfilesRequest");
for field in ["group_id", "duplicate_strategy", "wayfern_config"] {
assert!(
+30
View File
@@ -195,6 +195,17 @@ async fn main() {
.help("Direct path to the VPN worker config JSON file"),
),
)
.subcommand(
Command::new("xray-worker")
.about("Run an Xray-core worker process (internal use)")
.arg(Arg::new("action").required(true).help("Action (start)"))
.arg(
Arg::new("config-path")
.long("config-path")
.required(true)
.help("Direct path to the Xray worker config JSON file"),
),
)
.subcommand(
Command::new("mcp-bridge")
.about("Bridge stdio MCP to a local HTTP MCP server")
@@ -509,6 +520,25 @@ async fn main() {
log::error!("Invalid action for vpn-worker. Use 'start'");
process::exit(1);
}
} else if let Some(xray_matches) = matches.subcommand_matches("xray-worker") {
let action = xray_matches
.get_one::<String>("action")
.expect("action is required");
let config_path = xray_matches
.get_one::<String>("config-path")
.expect("config-path is required");
if action != "start" {
log::error!("Invalid action for xray-worker. Use 'start'");
process::exit(1);
}
set_high_priority();
if let Err(error) =
donutbrowser_lib::xray_worker_runner::run_xray_worker(std::path::Path::new(config_path)).await
{
log::error!("Xray worker failed: {error}");
process::exit(1);
}
} else if let Some(bridge_matches) = matches.subcommand_matches("mcp-bridge") {
let url = bridge_matches
.get_one::<String>("url")
+5 -2
View File
@@ -2,13 +2,15 @@ use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct ProxySettings {
pub proxy_type: String, // "http", "https", "socks4", "socks5", or "ss" (Shadowsocks)
pub proxy_type: String,
pub host: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vless_uri: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -581,6 +583,7 @@ mod tests {
port: 8080,
username: None,
password: None,
vless_uri: None,
};
// Test that it can be serialized (implements Serialize)
+179 -75
View File
@@ -6,9 +6,26 @@ use crate::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
static PROFILE_LAUNCH_LOCKS: LazyLock<
tokio::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
> = LazyLock::new(|| tokio::sync::Mutex::new(HashMap::new()));
async fn lock_profile_launch(profile_id: &str) -> tokio::sync::OwnedMutexGuard<()> {
let lock = {
let mut locks = PROFILE_LAUNCH_LOCKS.lock().await;
locks
.entry(profile_id.to_string())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
};
lock.lock_owned().await
}
pub struct BrowserRunner {
pub profile_manager: &'static ProfileManager,
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
@@ -181,18 +198,6 @@ impl BrowserRunner {
.map_err(|e| format!("Failed to get executable path for {}: {e}", profile.browser).into())
}
pub async fn launch_browser(
&self,
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
url: Option<String>,
local_proxy_settings: Option<&ProxySettings>,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
self
.launch_browser_internal(app_handle, profile, url, local_proxy_settings, None, false)
.await
}
async fn launch_browser_internal(
&self,
app_handle: tauri::AppHandle,
@@ -218,6 +223,52 @@ impl BrowserRunner {
.resolve_launch_proxy(profile)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
let geo_proxy_signature_settings = upstream_proxy.clone();
struct XrayLaunchGuard {
worker_id: Option<String>,
profile_name: String,
}
impl Drop for XrayLaunchGuard {
fn drop(&mut self) {
let Some(worker_id) = self.worker_id.take() else {
return;
};
log::warn!(
"Launch failed after Xray-core start for profile {}; stopping worker",
self.profile_name
);
if let Err(error) = crate::xray_worker_runner::stop_xray_worker_now(&worker_id) {
log::warn!("Failed to stop Xray-core worker after failed launch: {error}");
}
}
}
let mut xray_launch_guard = XrayLaunchGuard {
worker_id: None,
profile_name: profile.name.clone(),
};
if upstream_proxy
.as_ref()
.is_some_and(|proxy| proxy.proxy_type.eq_ignore_ascii_case("vless"))
{
let vless_uri = upstream_proxy
.as_ref()
.and_then(|proxy| proxy.vless_uri.as_deref())
.ok_or_else(|| crate::backend_error("VLESS_CONFIG_INVALID"))?;
let worker =
crate::xray_worker_runner::start_xray_worker(Some(&profile.id.to_string()), vless_uri)
.await
.map_err(|error| -> Box<dyn std::error::Error + Send + Sync> {
error.to_string().into()
})?;
log::info!(
"Xray-core worker started for Wayfern profile on port {}",
worker.local_port
);
xray_launch_guard.worker_id = Some(worker.id.clone());
upstream_proxy = Some(worker.local_proxy_settings());
}
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
@@ -231,6 +282,7 @@ impl BrowserRunner {
port,
username: None,
password: None,
vless_uri: None,
});
log::info!("VPN worker started for Wayfern profile on port {}", port);
}
@@ -287,7 +339,7 @@ impl BrowserRunner {
// would survive until machine reboot.
struct ProxyLaunchGuard {
app_handle: tauri::AppHandle,
placeholder_pid: u32,
routing_pid: u32,
profile_name: String,
armed: bool,
}
@@ -299,7 +351,7 @@ impl BrowserRunner {
self.profile_name
);
let app_handle = self.app_handle.clone();
let pid = self.placeholder_pid;
let pid = self.routing_pid;
tauri::async_runtime::spawn(async move {
if let Err(e) = PROXY_MANAGER.stop_proxy(app_handle, pid).await {
log::warn!("Failed to stop proxy worker after failed launch: {e}");
@@ -310,7 +362,7 @@ impl BrowserRunner {
}
let mut proxy_launch_guard = ProxyLaunchGuard {
app_handle: app_handle.clone(),
placeholder_pid: launch_placeholder_pid,
routing_pid: launch_placeholder_pid,
profile_name: profile.name.clone(),
armed: true,
};
@@ -369,7 +421,7 @@ impl BrowserRunner {
// later on-demand match can tell the location was never resolved.
updated_wayfern_config.geo_proxy_signature = if geolocation_applied {
Some(crate::wayfern_manager::WayfernManager::geo_signature(
upstream_proxy.as_ref(),
geo_proxy_signature_settings.as_ref(),
profile.vpn_id.as_deref(),
wayfern_config.geoip.as_ref(),
))
@@ -454,14 +506,52 @@ impl BrowserRunner {
format!("Failed to launch Wayfern: {e}").into()
})?;
// Browser is up and using the worker — failures past this point must
// not stop it.
proxy_launch_guard.armed = false;
// Get the process ID from launch result
let process_id = wayfern_result.processId.unwrap_or(0);
let Some(process_id) = wayfern_result.processId.filter(|pid| *pid != 0) else {
if let Err(error) = self.wayfern_manager.stop_wayfern(&wayfern_result.id).await {
log::warn!("Failed to stop Wayfern after it omitted its process ID: {error}");
}
return Err(
crate::backend_error_with_detail(
"INTERNAL_ERROR",
"Wayfern did not report a process identifier",
)
.into(),
);
};
log::info!("Wayfern launched successfully with PID: {process_id}");
if let Err(error) = PROXY_MANAGER.update_proxy_pid(launch_placeholder_pid, process_id) {
if let Err(stop_error) = self.wayfern_manager.stop_wayfern(&wayfern_result.id).await {
log::warn!("Failed to stop Wayfern after proxy PID mapping failed: {stop_error}");
}
return Err(crate::backend_error_with_detail("INTERNAL_ERROR", error).into());
}
proxy_launch_guard.routing_pid = process_id;
log::info!(
"Updated proxy PID mapping from launch placeholder {launch_placeholder_pid} to actual PID: {process_id}"
);
if !PROXY_MANAGER.set_browser_pid_for_profile(&updated_profile.id.to_string(), process_id) {
if let Err(error) = self.wayfern_manager.stop_wayfern(&wayfern_result.id).await {
log::warn!("Failed to stop Wayfern after proxy worker reassignment failed: {error}");
}
return Err(crate::backend_error("INTERNAL_ERROR").into());
}
if let Some(worker_id) = xray_launch_guard.worker_id.as_deref() {
if !crate::xray_worker_runner::set_browser_pid(worker_id, process_id) {
if let Err(error) = self.wayfern_manager.stop_wayfern(&wayfern_result.id).await {
log::warn!("Failed to stop Wayfern after Xray worker reassignment failed: {error}");
}
return Err(crate::backend_error("XRAY_START_FAILED").into());
}
}
// The browser and both detached routing workers now share one verified
// process identity, so later profile-persistence failures must not tear
// down a live route.
proxy_launch_guard.armed = false;
xray_launch_guard.worker_id = None;
// Wayfern.setFingerprint echoes back the fingerprint the browser actually
// applied, which may be UPGRADED from the stored one (e.g. when the
// stored fingerprint targets an older browser version). Persist it so the
@@ -484,24 +574,6 @@ impl BrowserRunner {
updated_profile.process_id = Some(process_id);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
// Update the proxy manager with the correct PID. When the browser
// reported no PID, keep the entry keyed by its unique placeholder (which
// the cleanup sweep skips) rather than remapping to a shared 0 key that
// concurrent launches could collide on.
if process_id != 0 {
if let Err(e) = PROXY_MANAGER.update_proxy_pid(launch_placeholder_pid, process_id) {
log::warn!("Warning: Failed to update proxy PID mapping: {e}");
} else {
log::info!(
"Updated proxy PID mapping from launch placeholder {launch_placeholder_pid} to actual PID: {process_id}"
);
}
}
// Persist the real browser PID so the detached proxy worker self-reaps
// when this browser dies, even after the GUI exits/restarts.
PROXY_MANAGER.set_browser_pid_for_profile(&updated_profile.id.to_string(), process_id);
// Save the updated profile
log::info!(
"Saving profile {} with wayfern_config fingerprint length: {}",
@@ -682,8 +754,7 @@ impl BrowserRunner {
final_profile.process_id.is_some()
);
if is_running && url.is_some() {
// Browser is running and we have a URL to open
if is_running {
if let Some(url_ref) = url.as_ref() {
log::info!(
"Opening {} in existing browser",
@@ -708,44 +779,15 @@ impl BrowserRunner {
"Failed to open URL in existing browser: {}",
crate::log_redaction::text(&e.to_string())
);
// Fall back to launching a new instance
log::info!(
"Falling back to new instance for browser: {}",
final_profile.browser
);
// Fallback to launching a new instance for other browsers
self
.launch_browser_internal(
app_handle.clone(),
&final_profile,
url,
internal_proxy_settings,
None,
false,
)
.await
Err(e)
}
}
} else {
// This case shouldn't happen since we checked is_some() above, but handle it gracefully
log::info!("URL was unexpectedly None, launching new browser instance");
self
.launch_browser(
app_handle.clone(),
&final_profile,
url,
internal_proxy_settings,
)
.await
log::info!("Browser is already running and no URL was requested");
Ok(final_profile)
}
} else {
// Browser is not running or no URL provided, launch new instance
if !is_running {
log::info!("Launching new browser instance - browser not running");
} else {
log::info!("Launching new browser instance - no URL provided");
}
log::info!("Launching new browser instance - browser not running");
self
.launch_browser_internal(
app_handle.clone(),
@@ -785,6 +827,17 @@ impl BrowserRunner {
&self,
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
self
.kill_browser_process_unlocked(app_handle, profile)
.await
}
async fn kill_browser_process_unlocked(
&self,
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Handle Wayfern profiles using WayfernManager
if profile.browser == "wayfern" {
@@ -810,6 +863,14 @@ impl BrowserRunner {
profile_id_str
);
}
if let Err(error) =
crate::xray_worker_runner::stop_xray_worker_by_profile_id(&profile_id_str).await
{
log::warn!(
"Warning: Failed to stop Xray-core worker for profile {}: {error}",
profile_id_str
);
}
let mut process_actually_stopped = false;
match self
@@ -1159,6 +1220,7 @@ impl BrowserRunner {
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| format!("Profile '{profile_id}' not found"))?;
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
if profile.is_cross_os() {
return Err(format!(
@@ -1209,6 +1271,7 @@ pub async fn launch_browser_profile_impl(
profile.name,
profile.id
);
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
if profile.is_cross_os() {
return Err(format!(
@@ -1259,6 +1322,17 @@ pub async fn launch_browser_profile_impl(
profile_for_launch.id
);
if force_new
&& browser_runner
.check_browser_status(app_handle.clone(), &profile_for_launch)
.await
.map_err(|error| {
crate::wrap_backend_error(error, "Failed to check browser status before launch")
})?
{
return Err(crate::backend_error("PROFILE_RUNNING"));
}
// Launch browser or open URL in existing instance. Wayfern starts its
// own local proxy inside `launch_browser_internal`; other browser types
// are rejected there, so no proxy needs to be staged here.
@@ -1339,7 +1413,6 @@ pub async fn kill_browser_profile(
profile.name,
profile.id
);
let browser_runner = BrowserRunner::instance();
match browser_runner
@@ -1447,6 +1520,37 @@ pub async fn open_url_with_profile(
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn profile_launch_lock_serializes_only_the_same_profile() {
let profile = format!("launch-lock-{}", uuid::Uuid::new_v4());
let other_profile = format!("launch-lock-{}", uuid::Uuid::new_v4());
let first = lock_profile_launch(&profile).await;
assert!(tokio::time::timeout(
Duration::from_millis(100),
lock_profile_launch(&other_profile)
)
.await
.is_ok());
assert!(
tokio::time::timeout(Duration::from_millis(100), lock_profile_launch(&profile))
.await
.is_err()
);
drop(first);
assert!(
tokio::time::timeout(Duration::from_millis(100), lock_profile_launch(&profile))
.await
.is_ok()
);
}
}
// Global singleton instance
lazy_static::lazy_static! {
static ref BROWSER_RUNNER: BrowserRunner = BrowserRunner::new();
+1
View File
@@ -973,6 +973,7 @@ impl CloudAuthManager {
port: config.port,
username: config.username,
password: config.password,
vless_uri: None,
};
match PROXY_MANAGER.upsert_cloud_proxy(settings) {
Ok(_) => {
+27 -10
View File
@@ -68,13 +68,18 @@ impl ConsistencyResult {
}
}
/// URL for handing this proxy to reqwest, or None for upstreams reqwest can't
/// drive (Shadowsocks).
fn proxy_url(settings: &crate::browser::ProxySettings) -> Option<String> {
/// URL for handing this proxy to reqwest. VLESS is reached through the
/// authenticated loopback Xray-core worker already serving the profile.
fn proxy_url(settings: &crate::browser::ProxySettings, profile_id: Option<&str>) -> Option<String> {
match settings.proxy_type.to_lowercase().as_str() {
"http" | "https" | "socks4" | "socks5" => Some(
crate::proxy_manager::ProxyManager::build_proxy_url(settings),
),
"vless" => profile_id
.and_then(crate::xray_worker_storage::find_xray_worker_by_profile_id)
.map(|worker| {
crate::proxy_manager::ProxyManager::build_proxy_url(&worker.local_proxy_settings())
}),
_ => None,
}
}
@@ -125,9 +130,15 @@ pub async fn check_profile_consistency(
let Some(settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) else {
return Ok(ConsistencyResult::skip());
};
let Some(url) = proxy_url(&settings) else {
let profile_id = profile.id.to_string();
let Some(url) = proxy_url(&settings, Some(&profile_id)) else {
return Ok(ConsistencyResult::skip());
};
let cache_identity = if settings.proxy_type.eq_ignore_ascii_case("vless") {
settings.vless_uri.clone().unwrap_or_else(|| url.clone())
} else {
url.clone()
};
let now = crate::proxy_manager::now_secs();
@@ -137,7 +148,9 @@ pub async fn check_profile_consistency(
let cache = EXIT_CACHE.lock().unwrap();
cache
.get(proxy_id)
.filter(|c| c.proxy_url == url && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS)
.filter(|c| {
c.proxy_url == cache_identity && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS
})
.cloned()
};
@@ -163,7 +176,7 @@ pub async fn check_profile_consistency(
proxy_id.clone(),
CachedExit {
fetched_at: now,
proxy_url: url.clone(),
proxy_url: cache_identity,
timezone: tz.clone(),
country_code: cc.clone(),
ip: ip.clone(),
@@ -339,8 +352,9 @@ mod tests {
port: 8080,
username: Some("u".into()),
password: Some("p".into()),
vless_uri: None,
};
assert_eq!(proxy_url(&http).as_deref(), Some("http://u:p@h:8080"));
assert_eq!(proxy_url(&http, None).as_deref(), Some("http://u:p@h:8080"));
// A password with URL-reserved characters must not break the authority —
// unencoded, the `/` truncates the host and reqwest targets `u` instead.
@@ -350,9 +364,10 @@ mod tests {
port: 8080,
username: Some("user".into()),
password: Some("ab/cd@ef".into()),
vless_uri: None,
};
assert_eq!(
proxy_url(&reserved).as_deref(),
proxy_url(&reserved, None).as_deref(),
Some("http://user:ab%2Fcd%40ef@gw.provider.io:8080")
);
@@ -363,9 +378,10 @@ mod tests {
port: 1080,
username: Some("justuser".into()),
password: None,
vless_uri: None,
};
assert_eq!(
proxy_url(&user_only).as_deref(),
proxy_url(&user_only, None).as_deref(),
Some("socks5://justuser@h:1080")
);
@@ -375,7 +391,8 @@ mod tests {
port: 8080,
username: None,
password: None,
vless_uri: None,
};
assert_eq!(proxy_url(&ss), None);
assert_eq!(proxy_url(&ss, None), None);
}
}
+3
View File
@@ -92,6 +92,9 @@ mod version_updater;
pub mod vpn;
pub mod vpn_worker_runner;
pub mod vpn_worker_storage;
pub mod xray;
pub mod xray_worker_runner;
pub mod xray_worker_storage;
use browser_runner::{
check_browser_exists, kill_browser_profile, launch_browser_profile, open_url_with_profile,
+102 -29
View File
@@ -937,8 +937,8 @@ impl McpServer {
},
"proxy_type": {
"type": "string",
"enum": ["http", "https", "socks4", "socks5"],
"description": "The type of proxy (for regular proxies)"
"enum": ["http", "https", "socks4", "socks5", "vless"],
"description": "The proxy protocol"
},
"host": {
"type": "string",
@@ -955,9 +955,13 @@ impl McpServer {
"password": {
"type": "string",
"description": "Optional password for authentication (for regular proxies)"
},
"vless_uri": {
"type": "string",
"description": "VLESS + XTLS Vision + REALITY share URI"
}
},
"required": ["name", "proxy_type", "host", "port"]
"required": ["name", "proxy_type"]
}),
},
McpTool {
@@ -976,8 +980,8 @@ impl McpServer {
},
"proxy_type": {
"type": "string",
"enum": ["http", "https", "socks4", "socks5"],
"description": "The type of proxy (for regular proxies)"
"enum": ["http", "https", "socks4", "socks5", "vless"],
"description": "The proxy protocol"
},
"host": {
"type": "string",
@@ -994,6 +998,10 @@ impl McpServer {
"password": {
"type": "string",
"description": "Optional password for authentication (for regular proxies)"
},
"vless_uri": {
"type": "string",
"description": "VLESS + XTLS Vision + REALITY share URI"
}
},
"required": ["proxy_id"]
@@ -3000,28 +3008,45 @@ impl McpServer {
// The tool schema declares an enum, but JSON-Schema enums are advisory only;
// enforce it here so a bad value can't produce a non-functional proxy.
if !matches!(proxy_type, "http" | "https" | "socks4" | "socks5") {
if !matches!(proxy_type, "http" | "https" | "socks4" | "socks5" | "vless") {
return Err(McpError {
code: -32602,
message: "proxy_type must be one of: http, https, socks4, socks5".to_string(),
message: "proxy_type must be one of: http, https, socks4, socks5, vless".to_string(),
});
}
let host = arguments
.get("host")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing host".to_string(),
})?;
let port = arguments
.get("port")
.and_then(|v| v.as_u64())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing port".to_string(),
})? as u16;
let vless_uri = arguments
.get("vless_uri")
.and_then(|value| value.as_str())
.map(str::to_string);
let (host, port) = if proxy_type == "vless" {
if vless_uri.is_none() {
return Err(McpError {
code: -32602,
message: "Missing vless_uri".to_string(),
});
}
(String::new(), 1)
} else {
let host = arguments
.get("host")
.and_then(|value| value.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing host".to_string(),
})?
.to_string();
let port = arguments
.get("port")
.and_then(|value| value.as_u64())
.and_then(|port| u16::try_from(port).ok())
.filter(|port| *port != 0)
.ok_or_else(|| McpError {
code: -32602,
message: "Missing or invalid port".to_string(),
})?;
(host, port)
};
let username = arguments
.get("username")
@@ -3034,10 +3059,11 @@ impl McpServer {
let proxy_settings = ProxySettings {
proxy_type: proxy_type.to_string(),
host: host.to_string(),
host,
port,
username,
password,
vless_uri,
};
let proxy = PROXY_MANAGER
@@ -3075,7 +3101,10 @@ impl McpServer {
// Build proxy_settings if any settings fields are provided
let has_settings = arguments.get("proxy_type").is_some()
|| arguments.get("host").is_some()
|| arguments.get("port").is_some();
|| arguments.get("port").is_some()
|| arguments.get("username").is_some()
|| arguments.get("password").is_some()
|| arguments.get("vless_uri").is_some();
let proxy_settings = if has_settings {
// Get existing proxy to use as defaults
@@ -3093,6 +3122,15 @@ impl McpServer {
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| existing.proxy_settings.proxy_type.clone());
if !matches!(
proxy_type.as_str(),
"http" | "https" | "socks4" | "socks5" | "vless"
) {
return Err(McpError {
code: -32602,
message: "proxy_type must be one of: http, https, socks4, socks5, vless".to_string(),
});
}
let host = arguments
.get("host")
@@ -3100,11 +3138,17 @@ impl McpServer {
.map(|s| s.to_string())
.unwrap_or_else(|| existing.proxy_settings.host.clone());
let port = arguments
.get("port")
.and_then(|v| v.as_u64())
.map(|p| p as u16)
.unwrap_or(existing.proxy_settings.port);
let port = match arguments.get("port") {
Some(value) => value
.as_u64()
.and_then(|port| u16::try_from(port).ok())
.filter(|port| *port != 0)
.ok_or_else(|| McpError {
code: -32602,
message: "Invalid port".to_string(),
})?,
None => existing.proxy_settings.port,
};
let username = arguments
.get("username")
@@ -3117,6 +3161,11 @@ impl McpServer {
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| existing.proxy_settings.password.clone());
let vless_uri = arguments
.get("vless_uri")
.and_then(|value| value.as_str())
.map(str::to_string)
.or_else(|| existing.proxy_settings.vless_uri.clone());
Some(ProxySettings {
proxy_type,
@@ -3124,6 +3173,7 @@ impl McpServer {
port,
username,
password,
vless_uri,
})
} else {
None
@@ -5560,6 +5610,29 @@ mod tests {
assert!(!server.is_running());
}
#[test]
fn proxy_tool_schema_exposes_vless_reality_without_requiring_regular_endpoint_fields() {
let server = McpServer::new();
let tools = server.get_tools();
let create = tools
.iter()
.find(|tool| tool.name == "create_proxy")
.expect("create_proxy tool");
let properties = &create.input_schema["properties"];
assert!(properties["proxy_type"]["enum"]
.as_array()
.is_some_and(|values| values.iter().any(|value| value == "vless")));
assert!(properties["vless_uri"].is_object());
let required = create.input_schema["required"]
.as_array()
.expect("required fields");
assert!(required.iter().any(|field| field == "name"));
assert!(required.iter().any(|field| field == "proxy_type"));
assert!(!required.iter().any(|field| field == "host"));
assert!(!required.iter().any(|field| field == "port"));
}
#[test]
fn rate_limit_only_classifies_browser_automation_tools() {
let request = |method: &str, name: Option<&str>| McpRequest {
+227 -21
View File
@@ -32,6 +32,8 @@ pub struct ExportedProxy {
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vless_uri: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -49,6 +51,8 @@ pub struct ParsedProxyLine {
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vless_uri: Option<String>,
pub original_line: String,
}
@@ -418,6 +422,31 @@ impl ProxyManager {
Ok(())
}
fn normalize_proxy_settings(mut proxy_settings: ProxySettings) -> Result<ProxySettings, String> {
if !proxy_settings.proxy_type.eq_ignore_ascii_case("vless") {
proxy_settings.vless_uri = None;
return Ok(proxy_settings);
}
let uri = proxy_settings
.vless_uri
.as_deref()
.filter(|uri| !uri.is_empty())
.ok_or_else(|| crate::backend_error("VLESS_CONFIG_INVALID"))?;
let parsed = crate::xray::parse_vless_uri(uri)
.map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?;
let canonical_uri = crate::xray::export_vless_uri(&parsed.config, parsed.name.as_deref())
.map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?;
proxy_settings.proxy_type = "vless".to_string();
proxy_settings.host = parsed.config.address;
proxy_settings.port = parsed.config.port;
proxy_settings.username = None;
proxy_settings.password = None;
proxy_settings.vless_uri = Some(canonical_uri);
Ok(proxy_settings)
}
// Create a new stored proxy
pub fn create_stored_proxy(
&self,
@@ -437,6 +466,7 @@ impl ProxyManager {
}
}
let proxy_settings = Self::normalize_proxy_settings(proxy_settings)?;
let stored_proxy = StoredProxy::new(name, proxy_settings);
{
@@ -670,6 +700,7 @@ impl ProxyManager {
port: base_proxy.proxy_settings.port,
username: Some(geo_username),
password: base_proxy.proxy_settings.password.clone(),
vless_uri: None,
};
// Check if name already exists
@@ -822,6 +853,10 @@ impl ProxyManager {
return Err(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string());
}
let proxy_settings = proxy_settings
.map(Self::normalize_proxy_settings)
.transpose()?;
// First, check for conflicts without holding a mutable reference
{
let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -1063,6 +1098,10 @@ impl ProxyManager {
// a password containing `/`, `#`, `?` or `@` otherwise breaks the URL
// authority and silently retargets the request at the wrong host.
pub fn build_proxy_url(proxy_settings: &ProxySettings) -> String {
if proxy_settings.proxy_type.eq_ignore_ascii_case("vless") {
return proxy_settings.vless_uri.clone().unwrap_or_default();
}
let mut url = format!("{}://", proxy_settings.proxy_type);
if let (Some(username), Some(password)) = (&proxy_settings.username, &proxy_settings.password) {
@@ -1090,9 +1129,22 @@ impl ProxyManager {
proxy_id: &str,
proxy_settings: &ProxySettings,
) -> Result<ProxyCheckResult, String> {
let upstream_url = Self::build_proxy_url(proxy_settings);
let mut xray_worker_id = None;
let effective_proxy_settings = if proxy_settings.proxy_type.eq_ignore_ascii_case("vless") {
let uri = proxy_settings
.vless_uri
.as_deref()
.ok_or_else(|| crate::backend_error("VLESS_CONFIG_INVALID"))?;
let worker = crate::xray_worker_runner::start_xray_worker(None, uri)
.await
.map_err(|error| error.to_string())?;
xray_worker_id = Some(worker.id.clone());
worker.local_proxy_settings()
} else {
proxy_settings.clone()
};
let upstream_url = Self::build_proxy_url(&effective_proxy_settings);
// Try process-based check first (identical to browser launch path)
// Try process-based check first (identical to browser launch path).
// If the proxy worker fails to start (e.g. Gatekeeper, antivirus, signing
// restrictions), fall back to a direct reqwest check.
@@ -1130,13 +1182,21 @@ impl ProxyManager {
result
}
Err(err_msg) => {
log::warn!(
"Proxy worker failed to start ({}), falling back to direct check",
err_msg
);
ip_utils::fetch_public_ip(Some(&upstream_url)).await
if xray_worker_id.is_some() {
log::warn!("Local proxy worker failed to start in front of Xray-core: {err_msg}");
Err(ip_utils::IpError::Network(err_msg))
} else {
log::warn!(
"Proxy worker failed to start ({}), falling back to direct check",
err_msg
);
ip_utils::fetch_public_ip(Some(&upstream_url)).await
}
}
};
if let Some(worker_id) = xray_worker_id {
let _ = crate::xray_worker_runner::stop_xray_worker(&worker_id).await;
}
let ip = match ip_result {
Ok(ip) => ip,
@@ -1195,6 +1255,7 @@ impl ProxyManager {
port: p.proxy_settings.port,
username: p.proxy_settings.username.clone(),
password: p.proxy_settings.password.clone(),
vless_uri: p.proxy_settings.vless_uri.clone(),
})
.collect();
@@ -1248,6 +1309,7 @@ impl ProxyManager {
port,
username: None,
password: None,
vless_uri: None,
original_line: line.to_string(),
});
}
@@ -1283,6 +1345,7 @@ impl ProxyManager {
port,
username: Some(parts[2].to_string()),
password: Some(parts[3].to_string()),
vless_uri: None,
original_line: line.to_string(),
})
}
@@ -1295,6 +1358,7 @@ impl ProxyManager {
port,
username: Some(parts[0].to_string()),
password: Some(parts[1].to_string()),
vless_uri: None,
original_line: line.to_string(),
})
}
@@ -1322,6 +1386,24 @@ impl ProxyManager {
// Try to parse URL format: protocol://username:password@host:port
fn try_parse_url_format(line: &str) -> Option<ProxyParseResult> {
if line.starts_with("vless://") {
return Some(match crate::xray::parse_vless_uri(line) {
Ok(parsed) => ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: "vless".to_string(),
host: parsed.config.address,
port: parsed.config.port,
username: None,
password: None,
vless_uri: Some(line.to_string()),
original_line: line.to_string(),
}),
Err(error) => ProxyParseResult::Invalid {
line: line.to_string(),
reason: error.to_string(),
},
});
}
// Check for protocol prefix using strip_prefix
let (protocol, rest) = if let Some(rest) = line.strip_prefix("http://") {
("http", rest)
@@ -1367,6 +1449,7 @@ impl ProxyManager {
port,
username,
password,
vless_uri: None,
original_line: line.to_string(),
}));
}
@@ -1382,6 +1465,7 @@ impl ProxyManager {
port,
username: None,
password: None,
vless_uri: None,
original_line: line.to_string(),
}));
}
@@ -1419,6 +1503,7 @@ impl ProxyManager {
port,
username,
password,
vless_uri: None,
original_line: line.to_string(),
}));
}
@@ -1447,6 +1532,7 @@ impl ProxyManager {
port: exported.port,
username: exported.username,
password: exported.password,
vless_uri: exported.vless_uri,
};
match self.create_stored_proxy(app_handle, exported.name.clone(), proxy_settings) {
@@ -1489,6 +1575,7 @@ impl ProxyManager {
port: parsed.port,
username: parsed.username,
password: parsed.password,
vless_uri: parsed.vless_uri,
};
match self.create_stored_proxy(app_handle, proxy_name.clone(), proxy_settings) {
@@ -1564,6 +1651,7 @@ impl ProxyManager {
port: existing.local_port,
username: None,
password: None,
vless_uri: None,
});
}
// Need to add this PID to the mapping - we'll do that after starting
@@ -1604,6 +1692,7 @@ impl ProxyManager {
port: existing.local_port,
username: None,
password: None,
vless_uri: None,
});
}
// Profile ID changed - we'll create a new proxy but don't stop the old one
@@ -1769,6 +1858,7 @@ impl ProxyManager {
port: proxy_info.local_port,
username: None,
password: None,
vless_uri: None,
})
}
@@ -1914,28 +2004,29 @@ impl ProxyManager {
/// placeholder until `update_proxy_pid` runs, so it is not a reliable way to
/// find the worker for a profile mid-launch. Safe on the reuse
/// path — it simply rewrites `browser_pid` to the new live PID. A `browser_pid`
/// of 0 (launch failed to report a PID) is ignored so the worker never
/// self-exits against a bogus PID.
pub fn set_browser_pid_for_profile(&self, profile_id: &str, browser_pid: u32) {
/// of 0 (launch failed to report a PID) is rejected so the caller can abort
/// the launch instead of leaving a worker without a verified browser owner.
pub fn set_browser_pid_for_profile(&self, profile_id: &str, browser_pid: u32) -> bool {
if browser_pid == 0 {
return;
return false;
}
let proxy_id = {
let map = self.profile_active_proxy_ids.lock().unwrap();
match map.get(profile_id) {
Some(id) => id.clone(),
None => return, // No local worker for this profile — nothing to tag.
None => return false,
}
};
if let Some(mut cfg) = crate::proxy_storage::get_proxy_config(&proxy_id) {
cfg.browser_pid = Some(browser_pid);
if crate::proxy_storage::update_proxy_config(&cfg) {
log::info!(
"Recorded browser PID {browser_pid} on proxy config {proxy_id} for self-reaping"
);
} else {
log::warn!("Failed to persist browser_pid {browser_pid} to proxy config {proxy_id}");
}
let Some(mut cfg) = crate::proxy_storage::get_proxy_config(&proxy_id) else {
return false;
};
cfg.browser_pid = Some(browser_pid);
if crate::proxy_storage::update_proxy_config(&cfg) {
log::info!("Recorded browser PID {browser_pid} on proxy config {proxy_id} for self-reaping");
true
} else {
log::warn!("Failed to persist browser_pid {browser_pid} to proxy config {proxy_id}");
false
}
}
@@ -2220,6 +2311,24 @@ impl ProxyManager {
}
}
{
use crate::proxy_storage::process_identity_matches;
use crate::xray_worker_storage::{list_xray_worker_configs, unstarted_worker_is_stale};
for worker in list_xray_worker_configs() {
let dead = worker
.pid
.is_some_and(|pid| !process_identity_matches(pid, worker.pid_start_time));
if dead || unstarted_worker_is_stale(&worker) {
log::info!(
"Cleaning up orphaned Xray-core worker config: {}",
worker.id
);
let _ = crate::xray_worker_runner::stop_xray_worker(&worker.id).await;
}
}
}
// Emit event for reactive UI updates
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
@@ -2355,6 +2464,7 @@ mod tests {
port: 8080,
username: Some("user".to_string()),
password: Some("pass".to_string()),
vless_uri: None,
};
assert!(
@@ -2382,6 +2492,7 @@ mod tests {
port: 0,
username: None,
password: None,
vless_uri: None,
};
assert!(
@@ -2536,6 +2647,7 @@ mod tests {
port: 8080,
username: Some("user".to_string()),
password: Some("pass".to_string()),
vless_uri: None,
};
// Test command arguments match expected format
@@ -3175,6 +3287,7 @@ mod tests {
port: 8080,
username: None,
password: None,
vless_uri: None,
});
assert_eq!(url, "http://1.2.3.4:8080");
@@ -3185,6 +3298,7 @@ mod tests {
port: 1080,
username: Some("user".to_string()),
password: Some("p@ss".to_string()),
vless_uri: None,
});
assert_eq!(url, "socks5://user:p%40ss@proxy.example.com:1080");
@@ -3195,10 +3309,101 @@ mod tests {
port: 3128,
username: Some("justuser".to_string()),
password: None,
vless_uri: None,
});
assert_eq!(url, "http://justuser@host.io:3128");
}
fn valid_vless_uri() -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
let public_key = URL_SAFE_NO_PAD.encode([7_u8; 32]);
format!(
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@127.0.0.1:443?\
encryption=none&flow=xtls-rprx-vision&security=reality&\
sni=www.example.com&fp=chrome&pbk={public_key}&\
sid=0123456789abcdef&spx=%2F&type=tcp&headerType=none#Local"
)
}
#[test]
fn vless_settings_are_validated_canonicalized_and_stripped_of_unused_credentials() {
let uri = valid_vless_uri();
let normalized = ProxyManager::normalize_proxy_settings(ProxySettings {
proxy_type: "VLESS".to_string(),
host: "ignored.invalid".to_string(),
port: 1,
username: Some("unused-user".to_string()),
password: Some("unused-password".to_string()),
vless_uri: Some(uri.clone()),
})
.unwrap();
assert_eq!(normalized.proxy_type, "vless");
assert_eq!(normalized.host, "127.0.0.1");
assert_eq!(normalized.port, 443);
assert!(normalized.username.is_none());
assert!(normalized.password.is_none());
assert_eq!(normalized.vless_uri.as_deref(), Some(uri.as_str()));
let invalid = uri.replace("security=reality", "security=tls");
let error = ProxyManager::normalize_proxy_settings(ProxySettings {
proxy_type: "vless".to_string(),
host: "127.0.0.1".to_string(),
port: 443,
username: None,
password: None,
vless_uri: Some(invalid.clone()),
})
.unwrap_err();
assert!(error.contains("VLESS_CONFIG_INVALID"));
assert!(!error.contains(&invalid));
}
#[test]
fn vless_stored_proxy_persistence_and_exports_preserve_the_canonical_uri() {
let temp = tempfile::tempdir().unwrap();
let _data_guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
let manager = ProxyManager::new();
let settings = ProxyManager::normalize_proxy_settings(ProxySettings {
proxy_type: "vless".to_string(),
host: "ignored.invalid".to_string(),
port: 1,
username: Some("unused".to_string()),
password: Some("unused".to_string()),
vless_uri: Some(valid_vless_uri()),
})
.unwrap();
let stored = StoredProxy::new("Local Reality".to_string(), settings);
manager.save_proxy(&stored).unwrap();
manager.upsert_stored_proxy(stored.clone());
let json: ProxyExportData =
serde_json::from_str(&manager.export_proxies_json().unwrap()).unwrap();
assert_eq!(json.proxies.len(), 1);
assert_eq!(json.proxies[0].proxy_type, "vless");
assert_eq!(json.proxies[0].vless_uri, stored.proxy_settings.vless_uri);
assert_eq!(
manager.export_proxies_txt(),
stored.proxy_settings.vless_uri.clone().unwrap()
);
let reloaded = ProxyManager::new()
.get_stored_proxies()
.into_iter()
.find(|candidate| candidate.id == stored.id)
.expect("persisted VLESS proxy should reload");
assert_eq!(reloaded.proxy_settings, stored.proxy_settings);
let parsed = ProxyManager::parse_txt_proxies(&manager.export_proxies_txt());
assert!(matches!(
parsed.as_slice(),
[ProxyParseResult::Parsed(proxy)]
if proxy.proxy_type == "vless"
&& proxy.vless_uri == stored.proxy_settings.vless_uri
));
}
#[test]
fn test_geo_username_construction() {
// Country only
@@ -3270,6 +3475,7 @@ mod tests {
port: 80,
username: None,
password: None,
vless_uri: None,
},
sync_enabled: false,
last_sync: None,
+48 -33
View File
@@ -50,27 +50,23 @@ fn prune_stale_proxy_logs(temp_dir: &Path, retain: usize) {
}
}
fn target_binary_name(base_name: &str) -> Option<String> {
let target = std::env::var("TARGET").ok()?;
fn target_binary_name(base_name: &str) -> String {
let target = env!("DONUT_BUILD_TARGET");
#[cfg(windows)]
{
Some(format!("{base_name}-{target}.exe"))
format!("{base_name}-{target}.exe")
}
#[cfg(not(windows))]
{
Some(format!("{base_name}-{target}"))
format!("{base_name}-{target}")
}
}
fn unsuffixed_binary_name(base_name: &str) -> String {
#[cfg(windows)]
{
match base_name {
"donut-proxy" => "donut-proxy.exe".to_string(),
_ => String::new(),
}
format!("{base_name}.exe")
}
#[cfg(not(windows))]
@@ -79,19 +75,24 @@ fn unsuffixed_binary_name(base_name: &str) -> String {
}
}
fn binary_matches_prefix(path: &Path, base_name: &str) -> bool {
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
fn is_executable_file(path: &Path) -> bool {
let Ok(metadata) = path.metadata() else {
return false;
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(windows)]
{
file_name.starts_with(&format!("{base_name}-")) && file_name.ends_with(".exe")
}
#[cfg(not(windows))]
{
file_name.starts_with(&format!("{base_name}-"))
path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("exe"))
}
}
@@ -156,10 +157,10 @@ pub(crate) fn find_sidecar_executable(
Some(manifest_dir.join("target").join("release")),
);
let mut exact_names = vec![unsuffixed_binary_name(base_name)];
if let Some(target_name) = target_binary_name(base_name) {
exact_names.push(target_name);
}
let exact_names = [
unsuffixed_binary_name(base_name),
target_binary_name(base_name),
];
for dir in &search_dirs {
for name in &exact_names {
@@ -168,19 +169,10 @@ pub(crate) fn find_sidecar_executable(
}
let candidate = dir.join(name);
if candidate.exists() {
if is_executable_file(&candidate) {
return Ok(candidate);
}
}
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && binary_matches_prefix(&path, base_name) {
return Ok(path);
}
}
}
}
Err(
@@ -565,7 +557,9 @@ pub async fn stop_all_proxy_processes() -> Result<(), Box<dyn std::error::Error>
#[cfg(test)]
mod tests {
use super::{parse_sidecar_version, prune_stale_proxy_logs};
use super::{
is_executable_file, parse_sidecar_version, prune_stale_proxy_logs, target_binary_name,
};
use std::fs;
use std::time::Duration;
@@ -592,6 +586,27 @@ mod tests {
);
}
#[test]
fn sidecar_target_name_is_exact_and_metadata_files_are_not_executables() {
let target_name = target_binary_name("xray");
assert!(target_name.starts_with("xray-"));
assert!(target_name.contains(env!("DONUT_BUILD_TARGET")));
let temp = tempfile::tempdir().unwrap();
let marker = temp.path().join(format!("{target_name}.source.json"));
fs::write(&marker, "{}").unwrap();
assert!(!is_executable_file(&marker));
let executable = temp.path().join(target_name);
fs::write(&executable, "binary").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
}
assert!(is_executable_file(&executable));
}
#[test]
fn prunes_only_old_proxy_logs() {
let temp = tempfile::tempdir().unwrap();
+139 -28
View File
@@ -139,22 +139,28 @@ impl BlocklistMatcher {
}
}
/// Wrapper stream that counts bytes read and written
#[derive(Clone, Copy)]
enum TrafficDirection {
Sent,
Received,
}
/// Wrapper stream that counts bytes successfully relayed to its destination.
struct CountingStream<S> {
inner: S,
bytes_read: Arc<AtomicU64>,
bytes_written: Arc<AtomicU64>,
write_direction: TrafficDirection,
// Resolved once per stream: the global tracker is fixed after init, so the
// hot poll paths avoid taking the global RwLock on every packet
tracker: Option<Arc<LiveTrafficTracker>>,
}
impl<S> CountingStream<S> {
fn new(inner: S) -> Self {
fn new(inner: S, write_direction: TrafficDirection) -> Self {
Self {
inner,
bytes_read: Arc::new(AtomicU64::new(0)),
bytes_written: Arc::new(AtomicU64::new(0)),
write_direction,
tracker: get_traffic_tracker(),
}
}
@@ -166,21 +172,7 @@ impl<S: AsyncRead + Unpin> AsyncRead for CountingStream<S> {
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let filled_before = buf.filled().len();
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
if let Poll::Ready(Ok(())) = &result {
let bytes_read = buf.filled().len() - filled_before;
if bytes_read > 0 {
self
.bytes_read
.fetch_add(bytes_read as u64, Ordering::Relaxed);
// Update global tracker - count as received (data coming into proxy)
if let Some(tracker) = &self.tracker {
tracker.add_bytes_received(bytes_read as u64);
}
}
}
result
Pin::new(&mut self.inner).poll_read(cx, buf)
}
}
@@ -193,9 +185,11 @@ impl<S: AsyncWrite + Unpin> AsyncWrite for CountingStream<S> {
let result = Pin::new(&mut self.inner).poll_write(cx, buf);
if let Poll::Ready(Ok(n)) = &result {
self.bytes_written.fetch_add(*n as u64, Ordering::Relaxed);
// Update global tracker - count as sent (data going out of proxy)
if let Some(tracker) = &self.tracker {
tracker.add_bytes_sent(*n as u64);
match self.write_direction {
TrafficDirection::Sent => tracker.add_bytes_sent(*n as u64),
TrafficDirection::Received => tracker.add_bytes_received(*n as u64),
}
}
}
result
@@ -2145,9 +2139,11 @@ pub(crate) async fn tunnel_streams(
target_stream: BoxedAsyncStream,
domain: String,
) {
// Wrap streams to count bytes transferred
let mut counting_client = CountingStream::new(client_stream);
let mut counting_target = CountingStream::new(target_stream);
// Count each payload byte once, when it is successfully written to its
// destination. Writes to the target are uploads; writes to the client are
// downloads.
let mut counting_client = CountingStream::new(client_stream, TrafficDirection::Received);
let mut counting_target = CountingStream::new(target_stream, TrafficDirection::Sent);
log::trace!("Starting bidirectional tunnel");
@@ -2165,10 +2161,8 @@ pub(crate) async fn tunnel_streams(
}
// Log final byte counts and update domain stats
let final_sent = counting_client.bytes_read.load(Ordering::Relaxed)
+ counting_target.bytes_written.load(Ordering::Relaxed);
let final_recv = counting_target.bytes_read.load(Ordering::Relaxed)
+ counting_client.bytes_written.load(Ordering::Relaxed);
let final_sent = counting_target.bytes_written.load(Ordering::Relaxed);
let final_recv = counting_client.bytes_written.load(Ordering::Relaxed);
log::trace!("Tunnel closed - sent: {final_sent} bytes, received: {final_recv} bytes");
// Update domain-specific byte counts now that tunnel is complete
@@ -2514,6 +2508,123 @@ mod tests {
feeder.abort();
}
#[tokio::test]
#[serial_test::serial]
async fn tunnel_traffic_counts_chunked_duplex_bytes_once_per_direction() {
let temp_dir = tempfile::tempdir().unwrap();
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp_dir.path().to_path_buf());
let profile_id = "traffic-counting-profile";
let domain = "counting.example";
init_traffic_tracker("traffic-counting-proxy".into(), Some(profile_id.into()));
let tracker = get_traffic_tracker().unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (browser_result, accepted_result) =
tokio::join!(TcpStream::connect(addr), listener.accept());
let browser_stream = browser_result.unwrap();
let (proxy_client_stream, _) = accepted_result.unwrap();
// Keep the duplex buffer deliberately small so client-to-target writes
// must make partial progress while both directions remain active.
let (proxy_target_stream, target_stream) = tokio::io::duplex(11);
let tunnel = tokio::spawn(tunnel_streams(
proxy_client_stream,
Box::new(proxy_target_stream),
domain.into(),
));
let upload_chunks = vec![vec![0x11; 3], vec![0x22; 31], vec![0x33; 8_193]];
let download_chunks = vec![vec![0x44; 5], vec![0x55; 47], vec![0x66; 5_003]];
let expected_upload = upload_chunks.concat();
let expected_download = download_chunks.concat();
let upload_len = expected_upload.len();
let download_len = expected_download.len();
let (browser_reader, mut browser_writer) = browser_stream.into_split();
let (target_reader, mut target_writer) = tokio::io::split(target_stream);
let transfer = async move {
let send_upload = async move {
for chunk in upload_chunks {
browser_writer.write_all(&chunk).await.unwrap();
tokio::task::yield_now().await;
}
browser_writer.flush().await.unwrap();
browser_writer
};
let send_download = async move {
for chunk in download_chunks {
target_writer.write_all(&chunk).await.unwrap();
tokio::task::yield_now().await;
}
target_writer.flush().await.unwrap();
target_writer
};
let receive_upload = async move {
let mut target_reader = target_reader;
let mut bytes = vec![0; upload_len];
target_reader.read_exact(&mut bytes).await.unwrap();
(target_reader, bytes)
};
let receive_download = async move {
let mut browser_reader = browser_reader;
let mut bytes = vec![0; download_len];
browser_reader.read_exact(&mut bytes).await.unwrap();
(browser_reader, bytes)
};
tokio::join!(send_upload, send_download, receive_upload, receive_download)
};
let (
mut browser_writer,
mut target_writer,
(target_reader, actual_upload),
(browser_reader, actual_download),
) = tokio::time::timeout(std::time::Duration::from_secs(5), transfer)
.await
.expect("duplex transfer timed out");
assert_eq!(actual_upload, expected_upload);
assert_eq!(actual_download, expected_download);
assert!(
!tunnel.is_finished(),
"the tunnel should remain live until its peers close"
);
assert_eq!(
tracker.get_snapshot(),
(upload_len as u64, download_len as u64, 0),
"global counters must update in real time without double-counting"
);
let (browser_shutdown, target_shutdown) =
tokio::join!(browser_writer.shutdown(), target_writer.shutdown());
browser_shutdown.unwrap();
target_shutdown.unwrap();
drop((browser_writer, target_writer, browser_reader, target_reader));
tokio::time::timeout(std::time::Duration::from_secs(5), tunnel)
.await
.expect("tunnel did not close")
.expect("tunnel task panicked");
assert_eq!(
tracker.get_snapshot(),
(upload_len as u64, download_len as u64, 0),
"closing the tunnel must not add another copy of its traffic"
);
assert_eq!(
tracker.flush_to_disk().unwrap(),
Some((upload_len as u64, download_len as u64))
);
let stats = crate::traffic_stats::load_traffic_stats(profile_id).unwrap();
assert_eq!(stats.total_bytes_sent, upload_len as u64);
assert_eq!(stats.total_bytes_received, download_len as u64);
let domain_stats = stats.domains.get(domain).unwrap();
assert_eq!(domain_stats.bytes_sent, upload_len as u64);
assert_eq!(domain_stats.bytes_received, download_len as u64);
}
#[test]
fn test_blocklist_comments_skipped() {
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
+22 -5
View File
@@ -202,19 +202,24 @@ pub fn generate_proxy_id() -> String {
)
}
pub fn is_process_running(pid: u32) -> bool {
pub fn process_start_time(pid: u32) -> Option<u64> {
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
let pid = sysinfo::Pid::from_u32(pid);
// Refresh only the queried PID with the minimal refresh kind: this is a
// pure existence check, and callers (worker supervisors every 15s, GUI
// cleanup loops) must not pay for a full system process-table scan.
let mut system = System::new();
system.refresh_processes_specifics(
ProcessesToUpdate::Some(&[pid]),
true,
ProcessRefreshKind::nothing(),
);
system.process(pid).is_some()
system.process(pid).map(sysinfo::Process::start_time)
}
pub fn is_process_running(pid: u32) -> bool {
process_start_time(pid).is_some()
}
pub fn process_identity_matches(pid: u32, expected_start_time: Option<u64>) -> bool {
expected_start_time.is_some_and(|expected| process_start_time(pid) == Some(expected))
}
#[cfg(test)]
@@ -236,6 +241,18 @@ mod tests {
);
}
#[test]
fn process_identity_requires_the_observed_start_time() {
let pid = std::process::id();
let start_time = process_start_time(pid).expect("current process should be visible");
assert!(process_identity_matches(pid, Some(start_time)));
assert!(!process_identity_matches(
pid,
Some(start_time.saturating_add(1))
));
assert!(!process_identity_matches(pid, None));
}
#[test]
fn test_is_process_running_detects_current_process() {
let pid = std::process::id();
+254
View File
@@ -0,0 +1,254 @@
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use super::{VlessRealityConfig, XrayError, XrayResult};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct XrayClientRuntime {
pub listen_port: u16,
pub username: String,
pub password: String,
}
impl XrayClientRuntime {
pub fn validate(&self) -> XrayResult<()> {
if self.listen_port == 0 {
return Err(XrayError::InvalidField {
field: "listen_port",
reason: "must be between 1 and 65535",
});
}
validate_socks_credential("username", &self.username)?;
validate_socks_credential("password", &self.password)
}
}
pub fn build_client_config(
config: &VlessRealityConfig,
runtime: &XrayClientRuntime,
) -> XrayResult<Value> {
config.validate()?;
runtime.validate()?;
Ok(json!({
"log": {
"loglevel": "warning"
},
"inbounds": [{
"tag": "local-socks",
"listen": "127.0.0.1",
"port": runtime.listen_port,
"protocol": "socks",
"settings": {
"auth": "password",
"accounts": [{
"user": runtime.username,
"pass": runtime.password
}],
"udp": true,
"ip": "127.0.0.1"
}
}],
"outbounds": [{
"tag": "proxy",
"protocol": "vless",
"settings": {
"vnext": [{
"address": config.address,
"port": config.port,
"users": [{
"id": config.id,
"encryption": "none",
"flow": config.flow.as_str()
}]
}]
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"show": false,
"fingerprint": config.reality.fingerprint.as_str(),
"serverName": config.reality.server_name,
"publicKey": config.reality.public_key,
"shortId": config.reality.short_id,
"spiderX": config.reality.spider_x
},
"sockopt": {
"tcpKeepAliveIdle": 30,
"tcpKeepAliveInterval": 15
}
}
}]
}))
}
pub fn build_client_config_json(
config: &VlessRealityConfig,
runtime: &XrayClientRuntime,
) -> XrayResult<String> {
serde_json::to_string_pretty(&build_client_config(config, runtime)?)
.map_err(|_| XrayError::Serialization)
}
fn validate_socks_credential(field: &'static str, value: &str) -> XrayResult<()> {
if value.is_empty() || value.len() > 255 {
return Err(XrayError::InvalidField {
field,
reason: "must contain between 1 and 255 URL-safe ASCII characters",
});
}
if !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~'))
{
return Err(XrayError::InvalidField {
field,
reason: "must contain only URL-safe ASCII characters",
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use super::super::{RealityFingerprint, RealitySettings, VlessFlow};
use super::*;
fn valid_config() -> VlessRealityConfig {
VlessRealityConfig {
address: "vpn.example.com".to_string(),
port: 443,
id: "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4".to_string(),
flow: VlessFlow::Vision,
reality: RealitySettings {
server_name: "www.example.com".to_string(),
public_key: URL_SAFE_NO_PAD.encode([7_u8; 32]),
short_id: "0123456789abcdef".to_string(),
fingerprint: RealityFingerprint::Chrome,
spider_x: "/".to_string(),
},
}
}
fn runtime() -> XrayClientRuntime {
XrayClientRuntime {
listen_port: 41_321,
username: "local_user-1".to_string(),
password: "local_password-1".to_string(),
}
}
#[test]
fn generates_minimal_authenticated_tcp_reality_config() {
let value = build_client_config(&valid_config(), &runtime()).unwrap();
assert_eq!(value["log"]["loglevel"], "warning");
assert_eq!(value["inbounds"].as_array().unwrap().len(), 1);
assert_eq!(value["inbounds"][0]["listen"], "127.0.0.1");
assert_eq!(value["inbounds"][0]["port"], 41_321);
assert_eq!(value["inbounds"][0]["protocol"], "socks");
assert_eq!(value["inbounds"][0]["settings"]["auth"], "password");
assert_eq!(
value["inbounds"][0]["settings"]["accounts"][0]["user"],
"local_user-1"
);
assert_eq!(
value["inbounds"][0]["settings"]["accounts"][0]["pass"],
"local_password-1"
);
assert_eq!(value["inbounds"][0]["settings"]["udp"], true);
assert_eq!(value["inbounds"][0]["settings"]["ip"], "127.0.0.1");
assert_eq!(value["outbounds"].as_array().unwrap().len(), 1);
assert_eq!(value["outbounds"][0]["protocol"], "vless");
assert_eq!(
value["outbounds"][0]["settings"]["vnext"][0]["address"],
"vpn.example.com"
);
assert_eq!(
value["outbounds"][0]["settings"]["vnext"][0]["users"][0]["encryption"],
"none"
);
assert_eq!(
value["outbounds"][0]["settings"]["vnext"][0]["users"][0]["flow"],
"xtls-rprx-vision"
);
assert_eq!(value["outbounds"][0]["streamSettings"]["network"], "tcp");
assert_eq!(
value["outbounds"][0]["streamSettings"]["security"],
"reality"
);
assert_eq!(
value["outbounds"][0]["streamSettings"]["realitySettings"]["fingerprint"],
"chrome"
);
assert_eq!(
value["outbounds"][0]["streamSettings"]["realitySettings"]["serverName"],
"www.example.com"
);
assert_eq!(
value["outbounds"][0]["streamSettings"]["realitySettings"]["shortId"],
"0123456789abcdef"
);
}
#[test]
fn does_not_add_bypass_or_observability_surfaces() {
let value = build_client_config(&valid_config(), &runtime()).unwrap();
for absent in ["api", "dns", "policy", "routing", "stats"] {
assert!(value.get(absent).is_none(), "{absent}");
}
assert!(value["outbounds"][0].get("mux").is_none());
assert!(!value.to_string().contains("freedom"));
assert!(!value.to_string().contains("blackhole"));
}
#[test]
fn json_output_round_trips_without_shape_changes() {
let value = build_client_config(&valid_config(), &runtime()).unwrap();
let json = build_client_config_json(&valid_config(), &runtime()).unwrap();
let reparsed: Value = serde_json::from_str(&json).unwrap();
assert_eq!(reparsed, value);
}
#[test]
fn runtime_requires_nonzero_port_and_url_safe_credentials() {
let cases = [
XrayClientRuntime {
listen_port: 0,
..runtime()
},
XrayClientRuntime {
username: String::new(),
..runtime()
},
XrayClientRuntime {
password: "contains:@".to_string(),
..runtime()
},
XrayClientRuntime {
username: "a".repeat(256),
..runtime()
},
];
for runtime in cases {
assert!(runtime.validate().is_err());
}
}
#[test]
fn invalid_model_is_rejected_before_generation() {
let mut config = valid_config();
config.reality.public_key = "secret-invalid-key".to_string();
let error = build_client_config(&config, &runtime()).unwrap_err();
assert!(matches!(
error,
XrayError::InvalidField { field: "pbk", .. }
));
assert!(!error.to_string().contains(&config.reality.public_key));
}
}
+29
View File
@@ -0,0 +1,29 @@
use thiserror::Error;
pub type XrayResult<T> = Result<T, XrayError>;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum XrayError {
#[error("invalid VLESS URI")]
InvalidUri,
#[error("URI scheme must be vless")]
UnsupportedScheme,
#[error("missing required field: {0}")]
MissingField(&'static str),
#[error("invalid field: {field} ({reason})")]
InvalidField {
field: &'static str,
reason: &'static str,
},
#[error("unsupported query parameter: {0}")]
UnsupportedParameter(String),
#[error("duplicate query parameter: {0}")]
DuplicateParameter(String),
#[error("unsupported value for {field}; expected {expected}")]
UnsupportedValue {
field: &'static str,
expected: &'static str,
},
#[error("failed to serialize Xray client configuration")]
Serialization,
}
+11
View File
@@ -0,0 +1,11 @@
mod client;
mod error;
mod model;
mod uri;
pub use client::{build_client_config, build_client_config_json, XrayClientRuntime};
pub use error::{XrayError, XrayResult};
pub use model::{
ParsedVlessUri, RealityFingerprint, RealitySettings, VlessFlow, VlessRealityConfig,
};
pub use uri::{export_vless_uri, parse_vless_uri};
+412
View File
@@ -0,0 +1,412 @@
use std::net::IpAddr;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde::{Deserialize, Serialize};
use url::Host;
use uuid::Uuid;
use super::{XrayError, XrayResult};
const MAX_SPIDER_X_BYTES: usize = 2048;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum VlessFlow {
#[default]
#[serde(rename = "xtls-rprx-vision")]
Vision,
}
impl VlessFlow {
pub const fn as_str(self) -> &'static str {
match self {
Self::Vision => "xtls-rprx-vision",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RealityFingerprint {
#[default]
Chrome,
Firefox,
Safari,
Edge,
Ios,
Android,
}
impl RealityFingerprint {
pub const fn as_str(self) -> &'static str {
match self {
Self::Chrome => "chrome",
Self::Firefox => "firefox",
Self::Safari => "safari",
Self::Edge => "edge",
Self::Ios => "ios",
Self::Android => "android",
}
}
pub(crate) fn parse(value: &str) -> XrayResult<Self> {
match value {
"chrome" => Ok(Self::Chrome),
"firefox" => Ok(Self::Firefox),
"safari" => Ok(Self::Safari),
"edge" => Ok(Self::Edge),
"ios" => Ok(Self::Ios),
"android" => Ok(Self::Android),
_ => Err(XrayError::UnsupportedValue {
field: "fp",
expected: "chrome, firefox, safari, edge, ios, or android",
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RealitySettings {
pub server_name: String,
pub public_key: String,
#[serde(default)]
pub short_id: String,
#[serde(default)]
pub fingerprint: RealityFingerprint,
#[serde(default = "default_spider_x")]
pub spider_x: String,
}
impl RealitySettings {
pub fn validate(&self) -> XrayResult<()> {
validate_server_name(&self.server_name)?;
validate_public_key(&self.public_key)?;
validate_short_id(&self.short_id)?;
validate_spider_x(&self.spider_x)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VlessRealityConfig {
pub address: String,
pub port: u16,
pub id: String,
#[serde(default)]
pub flow: VlessFlow,
pub reality: RealitySettings,
}
impl VlessRealityConfig {
pub fn validate(&self) -> XrayResult<()> {
validate_endpoint_address(&self.address)?;
if self.port == 0 {
return Err(XrayError::InvalidField {
field: "port",
reason: "must be between 1 and 65535",
});
}
Uuid::parse_str(&self.id).map_err(|_| XrayError::InvalidField {
field: "id",
reason: "must be a UUID",
})?;
self.reality.validate()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ParsedVlessUri {
pub name: Option<String>,
pub config: VlessRealityConfig,
}
impl ParsedVlessUri {
pub fn validate(&self) -> XrayResult<()> {
if let Some(name) = &self.name {
validate_display_name(name)?;
}
self.config.validate()
}
}
pub(crate) fn default_spider_x() -> String {
"/".to_string()
}
pub(crate) fn validate_display_name(name: &str) -> XrayResult<()> {
if name.is_empty() {
return Err(XrayError::InvalidField {
field: "name",
reason: "must not be empty",
});
}
if name.chars().count() > 200 {
return Err(XrayError::InvalidField {
field: "name",
reason: "must not exceed 200 characters",
});
}
if name.chars().any(char::is_control) {
return Err(XrayError::InvalidField {
field: "name",
reason: "must not contain control characters",
});
}
Ok(())
}
fn validate_endpoint_address(address: &str) -> XrayResult<()> {
if address.is_empty()
|| address.trim() != address
|| address.starts_with('[')
|| address.ends_with(']')
{
return Err(XrayError::InvalidField {
field: "address",
reason: "must be a valid hostname or IP address",
});
}
if address.parse::<IpAddr>().is_ok() {
return Ok(());
}
Host::parse(address).map_err(|_| XrayError::InvalidField {
field: "address",
reason: "must be a valid hostname or IP address",
})?;
Ok(())
}
fn validate_server_name(server_name: &str) -> XrayResult<()> {
if server_name.is_empty() || server_name.trim() != server_name {
return Err(XrayError::InvalidField {
field: "sni",
reason: "must be a valid DNS name",
});
}
match Host::parse(server_name) {
Ok(Host::Domain(_)) => Ok(()),
_ => Err(XrayError::InvalidField {
field: "sni",
reason: "must be a valid DNS name",
}),
}
}
fn validate_public_key(public_key: &str) -> XrayResult<()> {
let decoded = URL_SAFE_NO_PAD
.decode(public_key)
.map_err(|_| XrayError::InvalidField {
field: "pbk",
reason: "must be an unpadded base64url-encoded 32-byte key",
})?;
if decoded.len() != 32 || URL_SAFE_NO_PAD.encode(decoded) != public_key {
return Err(XrayError::InvalidField {
field: "pbk",
reason: "must be an unpadded base64url-encoded 32-byte key",
});
}
Ok(())
}
fn validate_short_id(short_id: &str) -> XrayResult<()> {
if short_id.len() > 16 || !short_id.len().is_multiple_of(2) {
return Err(XrayError::InvalidField {
field: "sid",
reason: "must be empty or contain up to 16 even-length hexadecimal characters",
});
}
if !short_id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(XrayError::InvalidField {
field: "sid",
reason: "must be empty or contain up to 16 even-length hexadecimal characters",
});
}
Ok(())
}
fn validate_spider_x(spider_x: &str) -> XrayResult<()> {
if !spider_x.starts_with('/') {
return Err(XrayError::InvalidField {
field: "spx",
reason: "must start with /",
});
}
if spider_x.len() > MAX_SPIDER_X_BYTES || spider_x.chars().any(char::is_control) {
return Err(XrayError::InvalidField {
field: "spx",
reason: "must be a valid relative path no longer than 2048 bytes",
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn public_key() -> String {
URL_SAFE_NO_PAD.encode([7_u8; 32])
}
fn valid_config() -> VlessRealityConfig {
VlessRealityConfig {
address: "vpn.example.com".to_string(),
port: 443,
id: "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4".to_string(),
flow: VlessFlow::Vision,
reality: RealitySettings {
server_name: "www.example.com".to_string(),
public_key: public_key(),
short_id: "0123456789abcdef".to_string(),
fingerprint: RealityFingerprint::Chrome,
spider_x: "/".to_string(),
},
}
}
#[test]
fn valid_model_passes_validation() {
assert_eq!(valid_config().validate(), Ok(()));
}
#[test]
fn endpoint_accepts_ipv4_ipv6_and_dns() {
for address in ["198.51.100.4", "2001:db8::1", "vpn.example.com"] {
let mut config = valid_config();
config.address = address.to_string();
assert_eq!(config.validate(), Ok(()), "{address}");
}
}
#[test]
fn endpoint_rejects_empty_whitespace_and_invalid_hosts() {
for address in [
"",
" vpn.example.com",
"vpn example.com",
"vpn.example.com:443",
"[2001:db8::1]",
] {
let mut config = valid_config();
config.address = address.to_string();
assert!(matches!(
config.validate(),
Err(XrayError::InvalidField {
field: "address",
..
})
));
}
}
#[test]
fn id_must_be_a_uuid() {
let mut config = valid_config();
config.id = "not-a-uuid".to_string();
assert_eq!(
config.validate(),
Err(XrayError::InvalidField {
field: "id",
reason: "must be a UUID",
})
);
}
#[test]
fn server_name_must_be_dns_name() {
for server_name in ["", "203.0.113.5", "bad server"] {
let mut config = valid_config();
config.reality.server_name = server_name.to_string();
assert!(matches!(
config.validate(),
Err(XrayError::InvalidField { field: "sni", .. })
));
}
}
#[test]
fn public_key_must_be_canonical_base64url_and_32_bytes() {
let short_key = URL_SAFE_NO_PAD.encode([1_u8; 31]);
for public_key in [
"not-base64!",
short_key.as_str(),
"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
] {
let mut config = valid_config();
config.reality.public_key = public_key.to_string();
let error = config.validate().unwrap_err();
assert!(matches!(
error,
XrayError::InvalidField { field: "pbk", .. }
));
assert!(!error.to_string().contains(public_key));
}
}
#[test]
fn short_id_accepts_empty_or_even_hex_up_to_sixteen_chars() {
for short_id in ["", "ab", "0123456789abcdef", "ABCDEF"] {
let mut config = valid_config();
config.reality.short_id = short_id.to_string();
assert_eq!(config.validate(), Ok(()), "{short_id}");
}
}
#[test]
fn short_id_rejects_odd_non_hex_and_overlong_values() {
for short_id in ["a", "xz", "0123456789abcdef00"] {
let mut config = valid_config();
config.reality.short_id = short_id.to_string();
assert!(matches!(
config.validate(),
Err(XrayError::InvalidField { field: "sid", .. })
));
}
}
#[test]
fn spider_x_must_be_safe_relative_path() {
for spider_x in ["relative", "/line\nbreak"] {
let mut config = valid_config();
config.reality.spider_x = spider_x.to_string();
assert!(matches!(
config.validate(),
Err(XrayError::InvalidField { field: "spx", .. })
));
}
}
#[test]
fn serde_defaults_preserve_the_supported_profile() {
let value = serde_json::json!({
"address": "vpn.example.com",
"port": 443,
"id": "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4",
"reality": {
"server_name": "www.example.com",
"public_key": public_key()
}
});
let config: VlessRealityConfig = serde_json::from_value(value).unwrap();
assert_eq!(config.flow, VlessFlow::Vision);
assert_eq!(config.reality.fingerprint, RealityFingerprint::Chrome);
assert_eq!(config.reality.short_id, "");
assert_eq!(config.reality.spider_x, "/");
}
#[test]
fn serde_rejects_unknown_configuration_fields() {
let value = serde_json::json!({
"address": "vpn.example.com",
"port": 443,
"id": "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4",
"transport": "websocket",
"reality": {
"server_name": "www.example.com",
"public_key": public_key()
}
});
assert!(serde_json::from_value::<VlessRealityConfig>(value).is_err());
}
}
+422
View File
@@ -0,0 +1,422 @@
use std::collections::HashMap;
use std::net::IpAddr;
use url::{Host, Url};
use uuid::Uuid;
use super::{
model::validate_display_name, ParsedVlessUri, RealityFingerprint, RealitySettings, VlessFlow,
VlessRealityConfig, XrayError, XrayResult,
};
const SUPPORTED_PARAMETERS: &[&str] = &[
"encryption",
"flow",
"security",
"sni",
"fp",
"pbk",
"sid",
"spx",
"type",
"headerType",
];
pub fn parse_vless_uri(input: &str) -> XrayResult<ParsedVlessUri> {
if input.trim() != input {
return Err(XrayError::InvalidUri);
}
let url = Url::parse(input).map_err(|_| XrayError::InvalidUri)?;
if url.scheme() != "vless" {
return Err(XrayError::UnsupportedScheme);
}
if url.password().is_some() {
return Err(XrayError::InvalidField {
field: "id",
reason: "password-style user information is not supported",
});
}
if !matches!(url.path(), "" | "/") {
return Err(XrayError::InvalidField {
field: "path",
reason: "VLESS TCP URIs must not contain a path",
});
}
let raw_id = url.username();
if raw_id.is_empty() {
return Err(XrayError::MissingField("id"));
}
let id = Uuid::parse_str(raw_id)
.map_err(|_| XrayError::InvalidField {
field: "id",
reason: "must be a UUID",
})?
.to_string();
let address = match url.host().ok_or(XrayError::MissingField("address"))? {
Host::Domain(value) => value.to_string(),
Host::Ipv4(value) => value.to_string(),
Host::Ipv6(value) => value.to_string(),
};
let port = url.port().ok_or(XrayError::MissingField("port"))?;
let parameters = parse_parameters(&url)?;
require_value(&parameters, "security", "reality")?;
require_value(&parameters, "flow", VlessFlow::Vision.as_str())?;
optional_value(&parameters, "encryption", "none")?;
match parameters.get("type").map(String::as_str) {
None | Some("tcp" | "raw") => {}
Some(_) => {
return Err(XrayError::UnsupportedValue {
field: "type",
expected: "tcp",
});
}
}
optional_value(&parameters, "headerType", "none")?;
let server_name = required_parameter(&parameters, "sni")?.to_string();
let public_key = required_parameter(&parameters, "pbk")?.to_string();
let short_id = parameters.get("sid").cloned().unwrap_or_default();
let spider_x = parameters
.get("spx")
.cloned()
.unwrap_or_else(|| "/".to_string());
let fingerprint = parameters
.get("fp")
.map(|value| RealityFingerprint::parse(value))
.transpose()?
.unwrap_or_default();
let name = url
.fragment()
.filter(|fragment| !fragment.is_empty())
.map(|fragment| {
urlencoding::decode(fragment)
.map(|value| value.into_owned())
.map_err(|_| XrayError::InvalidField {
field: "name",
reason: "must use valid percent encoding",
})
})
.transpose()?;
let parsed = ParsedVlessUri {
name,
config: VlessRealityConfig {
address,
port,
id,
flow: VlessFlow::Vision,
reality: RealitySettings {
server_name,
public_key,
short_id,
fingerprint,
spider_x,
},
},
};
parsed.validate()?;
Ok(parsed)
}
pub fn export_vless_uri(config: &VlessRealityConfig, name: Option<&str>) -> XrayResult<String> {
config.validate()?;
if let Some(name) = name {
validate_display_name(name)?;
}
let mut url = Url::parse("vless://placeholder@127.0.0.1").expect("static VLESS URL is valid");
url
.set_username(&config.id)
.map_err(|_| XrayError::InvalidUri)?;
let uri_host = match config.address.parse::<IpAddr>() {
Ok(IpAddr::V6(address)) => format!("[{address}]"),
_ => config.address.clone(),
};
url
.set_host(Some(&uri_host))
.map_err(|_| XrayError::InvalidField {
field: "address",
reason: "must be a valid hostname or IP address",
})?;
url
.set_port(Some(config.port))
.map_err(|_| XrayError::InvalidField {
field: "port",
reason: "must be between 1 and 65535",
})?;
{
let mut query = url.query_pairs_mut();
query.append_pair("encryption", "none");
query.append_pair("flow", config.flow.as_str());
query.append_pair("security", "reality");
query.append_pair("sni", &config.reality.server_name);
query.append_pair("fp", config.reality.fingerprint.as_str());
query.append_pair("pbk", &config.reality.public_key);
query.append_pair("sid", &config.reality.short_id);
query.append_pair("spx", &config.reality.spider_x);
query.append_pair("type", "tcp");
query.append_pair("headerType", "none");
}
url.set_fragment(name);
Ok(url.into())
}
fn parse_parameters(url: &Url) -> XrayResult<HashMap<String, String>> {
let mut parameters = HashMap::new();
for (name, value) in url.query_pairs() {
if !SUPPORTED_PARAMETERS.contains(&name.as_ref()) {
return Err(XrayError::UnsupportedParameter(name.into_owned()));
}
if parameters
.insert(name.to_string(), value.into_owned())
.is_some()
{
return Err(XrayError::DuplicateParameter(name.into_owned()));
}
}
Ok(parameters)
}
fn required_parameter<'a>(
parameters: &'a HashMap<String, String>,
name: &'static str,
) -> XrayResult<&'a str> {
parameters
.get(name)
.filter(|value| !value.is_empty())
.map(String::as_str)
.ok_or(XrayError::MissingField(name))
}
fn require_value(
parameters: &HashMap<String, String>,
name: &'static str,
expected: &'static str,
) -> XrayResult<()> {
let value = required_parameter(parameters, name)?;
if value != expected {
return Err(XrayError::UnsupportedValue {
field: name,
expected,
});
}
Ok(())
}
fn optional_value(
parameters: &HashMap<String, String>,
name: &'static str,
expected: &'static str,
) -> XrayResult<()> {
if parameters.get(name).is_some_and(|value| value != expected) {
return Err(XrayError::UnsupportedValue {
field: name,
expected,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use super::*;
const ID: &str = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
fn public_key() -> String {
URL_SAFE_NO_PAD.encode([7_u8; 32])
}
fn uri(overrides: &[(&str, &str)]) -> String {
let key = public_key();
let mut parameters = vec![
("encryption", "none"),
("flow", "xtls-rprx-vision"),
("security", "reality"),
("sni", "www.example.com"),
("fp", "chrome"),
("pbk", key.as_str()),
("sid", "0123456789abcdef"),
("spx", "/"),
("type", "tcp"),
("headerType", "none"),
];
for (name, value) in overrides {
if let Some(parameter) = parameters.iter_mut().find(|(key, _)| key == name) {
parameter.1 = value;
} else {
parameters.push((name, value));
}
}
let query = parameters
.into_iter()
.map(|(name, value)| format!("{name}={}", urlencoding::encode(value)))
.collect::<Vec<_>>()
.join("&");
format!("vless://{ID}@vpn.example.com:443?{query}#Primary")
}
#[test]
fn parses_supported_reality_vision_uri() {
let parsed = parse_vless_uri(&uri(&[])).unwrap();
assert_eq!(parsed.name.as_deref(), Some("Primary"));
assert_eq!(parsed.config.address, "vpn.example.com");
assert_eq!(parsed.config.port, 443);
assert_eq!(parsed.config.id, ID);
assert_eq!(parsed.config.flow, VlessFlow::Vision);
assert_eq!(parsed.config.reality.server_name, "www.example.com");
assert_eq!(parsed.config.reality.public_key, public_key());
assert_eq!(parsed.config.reality.short_id, "0123456789abcdef");
assert_eq!(
parsed.config.reality.fingerprint,
RealityFingerprint::Chrome
);
assert_eq!(parsed.config.reality.spider_x, "/");
}
#[test]
fn parses_ipv6_and_percent_encoded_metadata() {
let input = uri(&[("spx", "/search?q=hello world")])
.replace("vpn.example.com", "[2001:db8::1]")
.replace("#Primary", "#Home%20server");
let parsed = parse_vless_uri(&input).unwrap();
assert_eq!(parsed.config.address, "2001:db8::1");
assert_eq!(parsed.config.reality.spider_x, "/search?q=hello world");
assert_eq!(parsed.name.as_deref(), Some("Home server"));
}
#[test]
fn applies_only_safe_optional_defaults() {
let key = public_key();
let input = format!(
"vless://{ID}@vpn.example.com:443?flow=xtls-rprx-vision&security=reality&sni=www.example.com&pbk={key}"
);
let parsed = parse_vless_uri(&input).unwrap();
assert_eq!(
parsed.config.reality.fingerprint,
RealityFingerprint::Chrome
);
assert_eq!(parsed.config.reality.short_id, "");
assert_eq!(parsed.config.reality.spider_x, "/");
}
#[test]
fn accepts_raw_as_tcp_alias() {
assert!(parse_vless_uri(&uri(&[("type", "raw")])).is_ok());
}
#[test]
fn rejects_wrong_scheme_credentials_path_and_missing_port() {
let valid = uri(&[]);
let cases = [
valid.replacen("vless://", "https://", 1),
valid.replacen(ID, &format!("{ID}:password"), 1),
valid.replacen(":443?", ":443/path?", 1),
valid.replacen(":443?", "?", 1),
];
for input in cases {
assert!(parse_vless_uri(&input).is_err(), "{input}");
}
}
#[test]
fn rejects_missing_required_reality_values() {
let key = public_key();
let cases = [
format!("vless://{ID}@vpn.example.com:443?flow=xtls-rprx-vision&sni=www.example.com&pbk={key}"),
format!("vless://{ID}@vpn.example.com:443?security=reality&sni=www.example.com&pbk={key}"),
format!("vless://{ID}@vpn.example.com:443?flow=xtls-rprx-vision&security=reality&pbk={key}"),
format!("vless://{ID}@vpn.example.com:443?flow=xtls-rprx-vision&security=reality&sni=www.example.com"),
];
for input in cases {
assert!(parse_vless_uri(&input).is_err(), "{input}");
}
}
#[test]
fn rejects_unsupported_security_transport_flow_and_encryption() {
for (name, value) in [
("security", "tls"),
("type", "ws"),
("flow", ""),
("encryption", "auto"),
("headerType", "http"),
("fp", "unsafe"),
] {
assert!(
matches!(
parse_vless_uri(&uri(&[(name, value)])),
Err(XrayError::UnsupportedValue { .. } | XrayError::MissingField(_))
),
"{name}={value}"
);
}
}
#[test]
fn rejects_unknown_and_duplicate_parameters() {
assert_eq!(
parse_vless_uri(&uri(&[("serviceName", "unsupported")])),
Err(XrayError::UnsupportedParameter("serviceName".to_string()))
);
let input = uri(&[]).replace("#Primary", "&sni=duplicate.example.com#Primary");
assert_eq!(
parse_vless_uri(&input),
Err(XrayError::DuplicateParameter("sni".to_string()))
);
}
#[test]
fn rejects_invalid_uuid_key_short_id_and_spider_x_without_echoing_secrets() {
let invalid_key = "private-value-that-must-not-be-echoed";
let cases = [
uri(&[]).replacen(ID, "not-a-uuid", 1),
uri(&[("pbk", invalid_key)]),
uri(&[("sid", "xyz")]),
uri(&[("spx", "relative")]),
];
for input in cases {
let error = parse_vless_uri(&input).unwrap_err();
assert!(!error.to_string().contains(invalid_key));
assert!(!error.to_string().contains(ID));
}
}
#[test]
fn export_is_canonical_and_round_trips() {
let parsed = parse_vless_uri(&uri(&[("fp", "firefox")])).unwrap();
let exported = export_vless_uri(&parsed.config, Some("Home server")).unwrap();
assert!(exported.starts_with(&format!("vless://{ID}@vpn.example.com:443?")));
assert!(exported.contains("type=tcp"));
assert!(exported.contains("flow=xtls-rprx-vision"));
assert!(exported.ends_with("#Home%20server"));
let reparsed = parse_vless_uri(&exported).unwrap();
assert_eq!(
reparsed,
ParsedVlessUri {
name: Some("Home server".to_string()),
config: parsed.config,
}
);
}
#[test]
fn export_handles_ipv6_host() {
let mut parsed = parse_vless_uri(&uri(&[])).unwrap();
parsed.config.address = "2001:db8::1".to_string();
let exported = export_vless_uri(&parsed.config, None).unwrap();
assert!(exported.starts_with(&format!("vless://{ID}@[2001:db8::1]:443?")));
assert_eq!(
parse_vless_uri(&exported).unwrap().config.address,
"2001:db8::1"
);
}
}
+817
View File
@@ -0,0 +1,817 @@
use crate::proxy_runner::find_sidecar_executable;
use crate::proxy_storage::{is_process_running, process_identity_matches, process_start_time};
use crate::xray::{build_client_config_json, parse_vless_uri, XrayClientRuntime};
use crate::xray_worker_storage::{
create_xray_worker_log, delete_xray_worker_config, generate_xray_worker_id,
get_xray_worker_config, get_xray_worker_config_from_path, list_xray_worker_configs,
save_xray_worker_config, save_xray_worker_config_to_path, unstarted_worker_is_stale,
write_xray_runtime_config, xray_worker_config_path, xray_worker_log_path, XrayWorkerConfig,
};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
const STARTUP_TIMEOUT: Duration = Duration::from_secs(15);
const POLL_INTERVAL: Duration = Duration::from_millis(100);
const READY_CHECK_TIMEOUT: Duration = Duration::from_millis(750);
static XRAY_BINARY_VERIFIED: AtomicBool = AtomicBool::new(false);
static XRAY_START_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
fn resolve_process_start_time(pid: u32) -> Option<u64> {
for _ in 0..25 {
if let Some(start_time) = process_start_time(pid) {
return Some(start_time);
}
std::thread::sleep(Duration::from_millis(10));
}
None
}
fn structured_error(code: &str) -> Box<dyn std::error::Error> {
serde_json::json!({ "code": code }).to_string().into()
}
fn structured_error_with_detail(
code: &str,
detail: impl std::fmt::Display,
) -> Box<dyn std::error::Error> {
serde_json::json!({ "code": code, "params": { "detail": detail.to_string() } })
.to_string()
.into()
}
#[cfg(any(target_os = "macos", test))]
fn parse_macos_major_version(version: &str) -> Option<u64> {
version.trim().split('.').next()?.parse().ok()
}
#[cfg(target_os = "macos")]
fn ensure_supported_macos_version() -> Result<(), Box<dyn std::error::Error>> {
let output = Command::new("/usr/bin/sw_vers")
.arg("-productVersion")
.output()
.map_err(|_| structured_error("XRAY_UNAVAILABLE"))?;
let version = String::from_utf8_lossy(&output.stdout);
if !output.status.success() {
return Err(structured_error("XRAY_UNAVAILABLE"));
}
if parse_macos_major_version(&version).is_some_and(|major| major < 12) {
return Err(structured_error("XRAY_UNSUPPORTED_OS"));
}
Ok(())
}
fn ensure_xray_binary() -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
ensure_supported_macos_version()?;
let executable =
find_sidecar_executable("xray").map_err(|_| structured_error("XRAY_UNAVAILABLE"))?;
if XRAY_BINARY_VERIFIED.load(Ordering::Acquire) {
return Ok(executable);
}
let mut command = Command::new(&executable);
command.arg("version");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(CREATE_NO_WINDOW);
}
let output = command
.output()
.map_err(|_| structured_error("XRAY_UNAVAILABLE"))?;
let version_output = String::from_utf8_lossy(&output.stdout);
if !output.status.success() || !version_output.trim_start().starts_with("Xray ") {
return Err(structured_error("XRAY_UNAVAILABLE"));
}
XRAY_BINARY_VERIFIED.store(true, Ordering::Release);
Ok(executable)
}
async fn authenticated_socks_ready(config: &XrayWorkerConfig) -> bool {
if !config
.xray_pid
.is_some_and(|pid| process_identity_matches(pid, config.xray_pid_start_time))
{
return false;
}
matches!(
tokio::time::timeout(READY_CHECK_TIMEOUT, async {
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", config.local_port)).await?;
stream.write_all(&[5, 1, 2]).await?;
let mut method = [0_u8; 2];
stream.read_exact(&mut method).await?;
if method != [5, 2] {
return Err(std::io::Error::other(
"Xray SOCKS endpoint did not request password authentication",
));
}
let username = config.username.as_bytes();
let password = config.password.as_bytes();
let mut auth = Vec::with_capacity(username.len() + password.len() + 3);
auth.extend_from_slice(&[1, username.len() as u8]);
auth.extend_from_slice(username);
auth.push(password.len() as u8);
auth.extend_from_slice(password);
stream.write_all(&auth).await?;
let mut response = [0_u8; 2];
stream.read_exact(&mut response).await?;
if response != [1, 0] {
return Err(std::io::Error::other(
"Xray SOCKS endpoint rejected local authentication",
));
}
Ok::<(), std::io::Error>(())
})
.await,
Ok(Ok(()))
)
}
async fn wait_until_ready(
id: &str,
supervisor_pid: u32,
supervisor_start_time: u64,
) -> Result<XrayWorkerConfig, Box<dyn std::error::Error>> {
let deadline = tokio::time::Instant::now() + STARTUP_TIMEOUT;
loop {
if let Some(config) = get_xray_worker_config(id) {
if !process_identity_matches(supervisor_pid, Some(supervisor_start_time)) {
let log = std::fs::read_to_string(xray_worker_log_path(id)).unwrap_or_default();
if !log.is_empty() {
log::error!("Xray worker {id} exited during startup: {log}");
}
return Err(structured_error("XRAY_START_FAILED"));
}
if config.pid == Some(supervisor_pid)
&& config.pid_start_time == Some(supervisor_start_time)
&& config.ready
&& authenticated_socks_ready(&config).await
{
return Ok(config);
}
}
if tokio::time::Instant::now() >= deadline {
return Err(structured_error("XRAY_START_FAILED"));
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
pub async fn start_xray_worker(
profile_id: Option<&str>,
vless_uri: &str,
) -> Result<XrayWorkerConfig, Box<dyn std::error::Error>> {
let _start_guard = XRAY_START_LOCK.lock().await;
parse_vless_uri(vless_uri)
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
crate::proxy_runner::ensure_sidecar_version().await?;
ensure_xray_binary()?;
let owner_pid = std::process::id();
let owner_start_time =
resolve_process_start_time(owner_pid).ok_or_else(|| structured_error("XRAY_START_FAILED"))?;
for config in list_xray_worker_configs() {
let dead = config
.pid
.is_some_and(|pid| !process_identity_matches(pid, config.pid_start_time));
if dead || unstarted_worker_is_stale(&config) {
let _ = stop_xray_worker(&config.id).await;
}
}
if let Some(profile_id) = profile_id {
let mut workers = list_xray_worker_configs()
.into_iter()
.filter(|config| config.profile_id.as_deref() == Some(profile_id))
.collect::<Vec<_>>();
workers.sort_unstable_by_key(|config| std::cmp::Reverse(config.created_at));
let mut reusable = None;
for mut existing in workers {
let candidate = reusable.is_none()
&& existing.vless_uri == vless_uri
&& existing
.pid
.is_some_and(|pid| process_identity_matches(pid, existing.pid_start_time))
&& existing.ready
&& worker_is_leased_to(&existing, owner_pid, owner_start_time);
if candidate
&& persist_browser_identity(&mut existing, owner_pid, owner_start_time)
&& authenticated_socks_ready(&existing).await
{
reusable = Some(existing);
} else {
let _ = stop_xray_worker(&existing.id).await;
}
}
if let Some(existing) = reusable {
return Ok(existing);
}
}
let mut last_error = None;
for attempt in 1..=3 {
match spawn_xray_worker(profile_id, vless_uri).await {
Ok(worker) => return Ok(worker),
Err(error) => {
if attempt < 3 {
log::warn!(
"Xray worker startup attempt {attempt} failed; retrying with a new local port"
);
}
last_error = Some(error.to_string());
}
}
}
Err(
last_error
.map(Into::into)
.unwrap_or_else(|| structured_error("XRAY_START_FAILED")),
)
}
async fn spawn_xray_worker(
profile_id: Option<&str>,
vless_uri: &str,
) -> Result<XrayWorkerConfig, Box<dyn std::error::Error>> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let local_port = listener
.local_addr()
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?
.port();
drop(listener);
let id = generate_xray_worker_id();
let mut pending = PendingSupervisor {
id: id.clone(),
pid: None,
pid_start_time: None,
armed: true,
};
let username = format!("donut_{}", uuid::Uuid::new_v4().simple());
let password = uuid::Uuid::new_v4().simple().to_string();
let mut config = XrayWorkerConfig::new(
id.clone(),
profile_id.map(str::to_string),
vless_uri.to_string(),
local_port,
username,
password,
);
let owner_pid = std::process::id();
config.browser_pid = Some(owner_pid);
config.browser_pid_start_time = Some(
resolve_process_start_time(owner_pid).ok_or_else(|| structured_error("XRAY_START_FAILED"))?,
);
save_xray_worker_config(&config)
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let supervisor = find_sidecar_executable("donut-proxy")
.map_err(|_| structured_error("PROXY_SIDECAR_VERSION_MISMATCH"))?;
let config_path = xray_worker_config_path(&id);
let log_file = create_xray_worker_log(&id)
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let mut command = Command::new(supervisor);
command
.arg("xray-worker")
.arg("start")
.arg("--config-path")
.arg(&config_path)
.stdin(Stdio::null())
.stdout(Stdio::from(log_file.try_clone().map_err(|error| {
structured_error_with_detail("XRAY_START_FAILED", error)
})?))
.stderr(Stdio::from(log_file));
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
command.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW);
}
let mut child = command
.spawn()
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let supervisor_pid = child.id();
let Some(supervisor_start_time) = resolve_process_start_time(supervisor_pid) else {
terminate_supervisor_unchecked(supervisor_pid);
let _ = child.wait();
return Err(structured_error("XRAY_START_FAILED"));
};
pending.pid = Some(supervisor_pid);
pending.pid_start_time = Some(supervisor_start_time);
drop(spawn_supervisor_reaper(child));
let ready = wait_until_ready(&id, supervisor_pid, supervisor_start_time).await?;
pending.armed = false;
Ok(ready)
}
pub fn set_browser_pid(worker_id: &str, browser_pid: u32) -> bool {
if browser_pid == 0 {
return false;
}
let Some(browser_pid_start_time) = resolve_process_start_time(browser_pid) else {
log::warn!("Failed to resolve browser PID {browser_pid} identity for Xray worker {worker_id}");
return false;
};
let Some(mut config) = get_xray_worker_config(worker_id) else {
return false;
};
persist_browser_identity(&mut config, browser_pid, browser_pid_start_time)
}
fn persist_browser_identity(
config: &mut XrayWorkerConfig,
browser_pid: u32,
browser_pid_start_time: u64,
) -> bool {
config.browser_pid = Some(browser_pid);
config.browser_pid_start_time = Some(browser_pid_start_time);
if !crate::xray_worker_storage::update_xray_worker_config(config) {
log::warn!(
"Failed to persist browser PID {browser_pid} on Xray worker {}",
config.id
);
return false;
}
true
}
fn worker_is_leased_to(config: &XrayWorkerConfig, pid: u32, start_time: u64) -> bool {
config.browser_pid == Some(pid) && config.browser_pid_start_time == Some(start_time)
}
struct PendingSupervisor {
id: String,
pid: Option<u32>,
pid_start_time: Option<u64>,
armed: bool,
}
fn spawn_supervisor_reaper(mut child: Child) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let _ = child.wait();
})
}
impl Drop for PendingSupervisor {
fn drop(&mut self) {
if self.armed {
let xray_process = get_xray_worker_config(&self.id)
.and_then(|config| config.xray_pid.zip(config.xray_pid_start_time));
delete_xray_worker_config(&self.id);
if let Some(pid) = self.pid {
if self.pid_start_time.is_none() || process_identity_matches(pid, self.pid_start_time) {
terminate_supervisor_unchecked(pid);
}
}
if let Some((pid, start_time)) = xray_process {
if process_identity_matches(pid, Some(start_time)) {
terminate_process_unchecked(pid);
}
}
delete_xray_worker_config(&self.id);
}
}
}
#[cfg(unix)]
fn terminate_supervisor_unchecked(pid: u32) {
let group = -(pid as i32);
unsafe {
libc::kill(group, libc::SIGTERM);
}
std::thread::sleep(Duration::from_millis(250));
if is_process_running(pid) {
unsafe {
libc::kill(group, libc::SIGKILL);
}
}
}
#[cfg(windows)]
fn terminate_supervisor_unchecked(pid: u32) {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/T", "/F", "/PID", &pid.to_string()])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
#[cfg(unix)]
fn terminate_process_unchecked(pid: u32) {
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
std::thread::sleep(Duration::from_millis(250));
if is_process_running(pid) {
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
}
}
#[cfg(windows)]
fn terminate_process_unchecked(pid: u32) {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
pub fn stop_xray_worker_now(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
let Some(config) = get_xray_worker_config(id) else {
return Ok(false);
};
delete_xray_worker_config(id);
if let Some(pid) = config
.pid
.filter(|pid| process_identity_matches(*pid, config.pid_start_time))
{
terminate_supervisor_unchecked(pid);
}
if let Some(pid) = config
.xray_pid
.filter(|pid| process_identity_matches(*pid, config.xray_pid_start_time))
{
terminate_process_unchecked(pid);
}
delete_xray_worker_config(id);
Ok(true)
}
pub async fn stop_xray_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
stop_xray_worker_now(id)
}
pub async fn stop_xray_worker_by_profile_id(
profile_id: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let workers = list_xray_worker_configs()
.into_iter()
.filter(|config| config.profile_id.as_deref() == Some(profile_id))
.collect::<Vec<_>>();
let mut stopped = false;
for worker in workers {
stopped |= stop_xray_worker(&worker.id).await?;
}
Ok(stopped)
}
struct ManagedChild {
child: Child,
}
impl ManagedChild {
fn new(child: Child) -> Self {
Self { child }
}
fn id(&self) -> u32 {
self.child.id()
}
fn try_wait(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
self.child.try_wait()
}
}
impl Drop for ManagedChild {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
pub async fn run_xray_worker(config_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let mut config = get_xray_worker_config_from_path(config_path)
.ok_or_else(|| structured_error("XRAY_START_FAILED"))?;
let supervisor_pid = std::process::id();
config.ready = false;
config.xray_pid = None;
config.xray_pid_start_time = None;
config.pid = Some(supervisor_pid);
config.pid_start_time = Some(
resolve_process_start_time(supervisor_pid)
.ok_or_else(|| structured_error("XRAY_START_FAILED"))?,
);
save_xray_worker_config_to_path(&config, config_path)
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let parsed = parse_vless_uri(&config.vless_uri)
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
let runtime = XrayClientRuntime {
listen_port: config.local_port,
username: config.username.clone(),
password: config.password.clone(),
};
let runtime_json = build_client_config_json(&parsed.config, &runtime)
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
write_xray_runtime_config(&config.id, runtime_json.as_bytes())
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let runtime_path = crate::xray_worker_storage::xray_runtime_config_path(&config.id);
let executable = ensure_xray_binary()?;
let mut command = Command::new(executable);
command
.arg("run")
.arg("-c")
.arg(&runtime_path)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(CREATE_NO_WINDOW);
}
let child = command
.spawn()
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let mut child = ManagedChild::new(child);
let xray_pid = child.id();
config.xray_pid = Some(xray_pid);
config.xray_pid_start_time = Some(
resolve_process_start_time(xray_pid).ok_or_else(|| structured_error("XRAY_START_FAILED"))?,
);
save_xray_worker_config_to_path(&config, config_path)
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let readiness_result: Result<(), Box<dyn std::error::Error>> = async {
let deadline = tokio::time::Instant::now() + STARTUP_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(_)) => return Err(structured_error("XRAY_START_FAILED")),
Ok(None) => {}
Err(error) => {
return Err(structured_error_with_detail("XRAY_START_FAILED", error));
}
}
if authenticated_socks_ready(&config).await {
tokio::time::sleep(POLL_INTERVAL).await;
match child.try_wait() {
Ok(Some(_)) => return Err(structured_error("XRAY_START_FAILED")),
Ok(None) => {}
Err(error) => {
return Err(structured_error_with_detail("XRAY_START_FAILED", error));
}
}
if authenticated_socks_ready(&config).await {
return Ok(());
}
}
if tokio::time::Instant::now() >= deadline {
return Err(structured_error("XRAY_START_FAILED"));
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
.await;
if let Err(error) = readiness_result {
drop(child);
delete_xray_worker_config(&config.id);
return Err(error);
}
config.ready = true;
if let Err(error) = save_xray_worker_config_to_path(&config, config_path) {
drop(child);
delete_xray_worker_config(&config.id);
return Err(structured_error_with_detail("XRAY_START_FAILED", error));
}
let mut state_misses = 0_u8;
let mut browser_misses = 0_u8;
let result = loop {
match child.try_wait() {
Ok(Some(_)) => break Err(structured_error("XRAY_START_FAILED")),
Ok(None) => {}
Err(error) => break Err(structured_error_with_detail("XRAY_START_FAILED", error)),
}
match get_xray_worker_config_from_path(config_path) {
Some(latest) => {
state_misses = 0;
if let Some(browser_pid) = latest.browser_pid {
if process_identity_matches(browser_pid, latest.browser_pid_start_time) {
browser_misses = 0;
} else {
browser_misses += 1;
if browser_misses >= 2 {
break Ok(());
}
}
} else {
browser_misses = 0;
}
}
None => {
state_misses += 1;
if state_misses >= 2 {
break Ok(());
}
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
};
drop(child);
delete_xray_worker_config(&config.id);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn short_lived_child() -> Child {
Command::new("sh")
.args(["-c", "sleep 0.2"])
.spawn()
.unwrap()
}
#[cfg(windows)]
fn short_lived_child() -> Child {
Command::new("cmd")
.args(["/C", "ping -n 2 127.0.0.1 >NUL"])
.spawn()
.unwrap()
}
#[test]
fn supervisor_child_is_reaped_after_exit() {
let child = short_lived_child();
let pid = child.id();
let start_time = resolve_process_start_time(pid).unwrap();
spawn_supervisor_reaper(child).join().unwrap();
assert!(!process_identity_matches(pid, Some(start_time)));
}
fn readiness_config(port: u16) -> XrayWorkerConfig {
let mut config = XrayWorkerConfig::new(
"readiness".to_string(),
None,
"vless://unused".to_string(),
port,
"local-user".to_string(),
"local-password".to_string(),
);
let pid = std::process::id();
config.xray_pid = Some(pid);
config.xray_pid_start_time = process_start_time(pid);
config
}
#[test]
fn parses_macos_major_versions_for_sidecar_compatibility() {
assert_eq!(parse_macos_major_version("11.7.10\n"), Some(11));
assert_eq!(parse_macos_major_version("12.0"), Some(12));
assert_eq!(parse_macos_major_version("15.5.1"), Some(15));
assert_eq!(parse_macos_major_version("unknown"), None);
}
#[tokio::test]
async fn readiness_requires_the_expected_authenticated_socks_endpoint() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let config = readiness_config(listener.local_addr().unwrap().port());
let expected_username = config.username.clone();
let expected_password = config.password.clone();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut greeting = [0_u8; 3];
stream.read_exact(&mut greeting).await.unwrap();
assert_eq!(greeting, [5, 1, 2]);
stream.write_all(&[5, 2]).await.unwrap();
let mut header = [0_u8; 2];
stream.read_exact(&mut header).await.unwrap();
assert_eq!(header[0], 1);
let mut username = vec![0_u8; header[1] as usize];
stream.read_exact(&mut username).await.unwrap();
let password_len = stream.read_u8().await.unwrap();
let mut password = vec![0_u8; password_len as usize];
stream.read_exact(&mut password).await.unwrap();
assert_eq!(username, expected_username.as_bytes());
assert_eq!(password, expected_password.as_bytes());
stream.write_all(&[1, 0]).await.unwrap();
});
assert!(authenticated_socks_ready(&config).await);
server.await.unwrap();
}
#[tokio::test]
async fn readiness_rejects_an_unrelated_listener_on_the_reserved_port() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let config = readiness_config(listener.local_addr().unwrap().port());
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut greeting = [0_u8; 3];
stream.read_exact(&mut greeting).await.unwrap();
stream.write_all(&[5, 0]).await.unwrap();
});
assert!(!authenticated_socks_ready(&config).await);
server.await.unwrap();
}
#[test]
fn browser_identity_is_persisted_on_the_exact_worker() {
let temp = tempfile::tempdir().unwrap();
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
let id = format!("xray-browser-owner-{}", uuid::Uuid::new_v4());
let config = XrayWorkerConfig::new(
id.clone(),
Some("profile".to_string()),
"vless://unused".to_string(),
1080,
"local-user".to_string(),
"local-password".to_string(),
);
save_xray_worker_config(&config).unwrap();
let browser_pid = std::process::id();
assert!(set_browser_pid(&id, browser_pid));
let saved = get_xray_worker_config(&id).unwrap();
assert_eq!(saved.browser_pid, Some(browser_pid));
assert_eq!(
saved.browser_pid_start_time,
process_start_time(browser_pid)
);
assert!(delete_xray_worker_config(&id));
}
#[test]
fn reusable_worker_requires_and_persists_the_exact_live_owner() {
let temp = tempfile::tempdir().unwrap();
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
let id = format!("xray-worker-lease-{}", uuid::Uuid::new_v4());
let mut config = XrayWorkerConfig::new(
id.clone(),
Some("profile".to_string()),
"vless://unused".to_string(),
1080,
"local-user".to_string(),
"local-password".to_string(),
);
config.browser_pid = Some(u32::MAX);
config.browser_pid_start_time = Some(1);
save_xray_worker_config(&config).unwrap();
let owner_pid = std::process::id();
let owner_start_time = process_start_time(owner_pid).unwrap();
assert!(!worker_is_leased_to(&config, owner_pid, owner_start_time));
assert!(persist_browser_identity(
&mut config,
owner_pid,
owner_start_time
));
assert!(worker_is_leased_to(&config, owner_pid, owner_start_time));
let saved = get_xray_worker_config(&id).unwrap();
assert_eq!(saved.browser_pid, Some(owner_pid));
assert_eq!(saved.browser_pid_start_time, Some(owner_start_time));
assert!(delete_xray_worker_config(&id));
}
}
+443
View File
@@ -0,0 +1,443 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
const UNSTARTED_WORKER_GRACE_SECS: u64 = 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct XrayWorkerConfig {
pub id: String,
pub profile_id: Option<String>,
pub vless_uri: String,
pub local_port: u16,
pub username: String,
pub password: String,
#[serde(default)]
pub created_at: u64,
pub pid: Option<u32>,
#[serde(default)]
pub pid_start_time: Option<u64>,
pub xray_pid: Option<u32>,
#[serde(default)]
pub xray_pid_start_time: Option<u64>,
#[serde(default)]
pub ready: bool,
#[serde(default)]
pub browser_pid: Option<u32>,
#[serde(default)]
pub browser_pid_start_time: Option<u64>,
}
impl XrayWorkerConfig {
pub fn new(
id: String,
profile_id: Option<String>,
vless_uri: String,
local_port: u16,
username: String,
password: String,
) -> Self {
Self {
id,
profile_id,
vless_uri,
local_port,
username,
password,
created_at: now_secs(),
pid: None,
pid_start_time: None,
xray_pid: None,
xray_pid_start_time: None,
ready: false,
browser_pid: None,
browser_pid_start_time: None,
}
}
pub fn local_proxy_settings(&self) -> crate::browser::ProxySettings {
crate::browser::ProxySettings {
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port: self.local_port,
username: Some(self.username.clone()),
password: Some(self.password.clone()),
vless_uri: None,
}
}
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn unstarted_worker_is_stale(config: &XrayWorkerConfig) -> bool {
config.pid.is_none()
&& (config.created_at == 0
|| now_secs().saturating_sub(config.created_at) > UNSTARTED_WORKER_GRACE_SECS)
}
fn ensure_private_storage_dir() -> std::io::Result<PathBuf> {
let directory = crate::proxy_storage::get_storage_dir();
fs::create_dir_all(&directory)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?;
}
Ok(directory)
}
fn atomic_write_owner_only(path: &Path, content: &[u8]) -> std::io::Result<()> {
let parent = path
.parent()
.ok_or_else(|| std::io::Error::other("worker path has no parent"))?;
fs::create_dir_all(parent)?;
let mut temporary = tempfile::Builder::new()
.prefix(".xray-state-")
.tempfile_in(parent)?;
crate::app_dirs::restrict_to_owner(temporary.path());
temporary.write_all(content)?;
temporary.flush()?;
temporary.as_file().sync_all()?;
temporary.persist(path).map_err(|error| error.error)?;
crate::app_dirs::restrict_to_owner(path);
Ok(())
}
pub fn xray_worker_config_path(id: &str) -> PathBuf {
crate::proxy_storage::get_storage_dir().join(format!("xray_worker_{id}.json"))
}
pub fn xray_runtime_config_path(id: &str) -> PathBuf {
crate::proxy_storage::get_storage_dir().join(format!("xray_runtime_{id}.json"))
}
pub fn xray_worker_log_path(id: &str) -> PathBuf {
crate::proxy_storage::get_storage_dir().join(format!("xray_worker_{id}.log"))
}
fn xray_worker_tombstone_path(id: &str) -> PathBuf {
crate::proxy_storage::get_storage_dir().join(format!("xray_worker_{id}.stopped"))
}
fn worker_is_tombstoned(id: &str) -> bool {
xray_worker_tombstone_path(id).exists()
}
pub fn create_xray_worker_log(id: &str) -> std::io::Result<std::fs::File> {
ensure_private_storage_dir()?;
if worker_is_tombstoned(id) {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Xray worker has stopped",
));
}
let path = xray_worker_log_path(id);
let file = crate::app_dirs::create_owner_only(&path)?;
if worker_is_tombstoned(id) {
drop(file);
let _ = fs::remove_file(path);
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Xray worker has stopped",
));
}
Ok(file)
}
pub fn write_xray_runtime_config(id: &str, content: &[u8]) -> std::io::Result<()> {
ensure_private_storage_dir()?;
if worker_is_tombstoned(id) {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Xray worker has stopped",
));
}
let path = xray_runtime_config_path(id);
atomic_write_owner_only(&path, content)?;
if worker_is_tombstoned(id) {
let _ = fs::remove_file(path);
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Xray worker has stopped",
));
}
Ok(())
}
pub fn save_xray_worker_config(
config: &XrayWorkerConfig,
) -> Result<(), Box<dyn std::error::Error>> {
save_xray_worker_config_to_path(config, &xray_worker_config_path(&config.id))
}
pub fn save_xray_worker_config_to_path(
config: &XrayWorkerConfig,
path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
ensure_private_storage_dir()?;
if worker_is_tombstoned(&config.id) {
return Err(
std::io::Error::new(std::io::ErrorKind::NotFound, "Xray worker has stopped").into(),
);
}
let content = serde_json::to_vec_pretty(config)?;
atomic_write_owner_only(path, &content)?;
if worker_is_tombstoned(&config.id) {
let _ = fs::remove_file(path);
return Err(
std::io::Error::new(std::io::ErrorKind::NotFound, "Xray worker has stopped").into(),
);
}
Ok(())
}
pub fn get_xray_worker_config(id: &str) -> Option<XrayWorkerConfig> {
get_xray_worker_config_from_path(&xray_worker_config_path(id))
}
pub fn get_xray_worker_config_from_path(path: &Path) -> Option<XrayWorkerConfig> {
let content = fs::read(path).ok()?;
serde_json::from_slice(&content).ok()
}
pub fn update_xray_worker_config(config: &XrayWorkerConfig) -> bool {
let path = xray_worker_config_path(&config.id);
path.exists() && save_xray_worker_config_to_path(config, &path).is_ok()
}
pub fn delete_xray_worker_config(id: &str) -> bool {
if ensure_private_storage_dir().is_ok() {
let _ = atomic_write_owner_only(&xray_worker_tombstone_path(id), b"");
}
let path = xray_worker_config_path(id);
let deleted = !path.exists() || fs::remove_file(path).is_ok();
let _ = fs::remove_file(xray_runtime_config_path(id));
let _ = fs::remove_file(xray_worker_log_path(id));
deleted
}
pub fn list_xray_worker_configs() -> Vec<XrayWorkerConfig> {
let storage_dir = crate::proxy_storage::get_storage_dir();
let Ok(entries) = fs::read_dir(storage_dir) else {
return Vec::new();
};
entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
let name = path.file_name()?.to_str()?;
if !name.starts_with("xray_worker_") || !name.ends_with(".json") {
return None;
}
get_xray_worker_config_from_path(&path)
})
.collect()
}
pub fn find_xray_worker_by_profile_id(profile_id: &str) -> Option<XrayWorkerConfig> {
list_xray_worker_configs()
.into_iter()
.filter(|config| config.profile_id.as_deref() == Some(profile_id))
.max_by_key(|config| config.created_at)
}
pub fn generate_xray_worker_id() -> String {
format!(
"xrayw_{}_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
rand::random::<u32>()
)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config(id: &str) -> XrayWorkerConfig {
XrayWorkerConfig::new(
id.to_string(),
Some("profile".to_string()),
"vless://example".to_string(),
1080,
"local-user".to_string(),
"local-password".to_string(),
)
}
#[test]
fn local_proxy_settings_use_authenticated_loopback_socks() {
let config = test_config("id");
let proxy = config.local_proxy_settings();
assert_eq!(proxy.proxy_type, "socks5");
assert_eq!(proxy.host, "127.0.0.1");
assert_eq!(proxy.port, 1080);
assert_eq!(proxy.username.as_deref(), Some("local-user"));
assert_eq!(proxy.password.as_deref(), Some("local-password"));
assert!(proxy.vless_uri.is_none());
}
#[test]
fn worker_storage_round_trips_updates_lists_and_securely_cleans_runtime_files() {
let temp = tempfile::tempdir().unwrap();
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
let id = format!("xray-storage-test-{}", uuid::Uuid::new_v4());
let mut config = test_config(&id);
save_xray_worker_config(&config).unwrap();
assert_eq!(get_xray_worker_config(&id).unwrap().username, "local-user");
assert_eq!(
find_xray_worker_by_profile_id("profile").unwrap().id,
config.id
);
assert!(list_xray_worker_configs()
.iter()
.any(|candidate| candidate.id == id));
config.pid = Some(41);
config.xray_pid = Some(42);
config.browser_pid = Some(43);
assert!(update_xray_worker_config(&config));
let updated = get_xray_worker_config(&id).unwrap();
assert_eq!(updated.pid, Some(41));
assert_eq!(updated.xray_pid, Some(42));
assert_eq!(updated.browser_pid, Some(43));
let runtime_path = xray_runtime_config_path(&id);
write_xray_runtime_config(&id, b"{\"runtime\":true}").unwrap();
let log_path = xray_worker_log_path(&id);
drop(create_xray_worker_log(&id).unwrap());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(crate::proxy_storage::get_storage_dir())
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert_eq!(
std::fs::metadata(xray_worker_config_path(&id))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
assert_eq!(
std::fs::metadata(&runtime_path)
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
assert_eq!(
std::fs::metadata(&log_path).unwrap().permissions().mode() & 0o777,
0o600
);
}
assert!(delete_xray_worker_config(&id));
assert!(get_xray_worker_config(&id).is_none());
assert!(!runtime_path.exists());
assert!(!log_path.exists());
assert!(!update_xray_worker_config(&config));
assert!(write_xray_runtime_config(&id, b"{}").is_err());
assert!(create_xray_worker_log(&id).is_err());
}
#[test]
fn fresh_unstarted_workers_have_a_grace_period_but_legacy_entries_are_stale() {
let fresh = test_config("fresh");
assert!(!unstarted_worker_is_stale(&fresh));
let mut legacy = test_config("legacy");
legacy.created_at = 0;
assert!(unstarted_worker_is_stale(&legacy));
legacy.pid = Some(1);
assert!(!unstarted_worker_is_stale(&legacy));
}
#[test]
fn atomic_state_updates_never_expose_partial_json() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("state.json");
atomic_write_owner_only(&path, br#"{"value":0}"#).unwrap();
let complete = Arc::new(AtomicBool::new(false));
let writer_complete = complete.clone();
let writer_path = path.clone();
let writer = std::thread::spawn(move || {
for value in 1..=500 {
let content = serde_json::to_vec(&serde_json::json!({ "value": value })).unwrap();
atomic_write_owner_only(&writer_path, &content).unwrap();
}
writer_complete.store(true, Ordering::Release);
});
while !complete.load(Ordering::Acquire) {
let content = std::fs::read(&path).unwrap();
let value: serde_json::Value = serde_json::from_slice(&content).unwrap();
assert!(value["value"].is_number());
}
writer.join().unwrap();
}
#[cfg(unix)]
#[test]
fn atomic_state_write_replaces_a_symlink_without_touching_its_target() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let victim = temp.path().join("victim");
let state = temp.path().join("state.json");
std::fs::write(&victim, "untouched").unwrap();
symlink(&victim, &state).unwrap();
atomic_write_owner_only(&state, br#"{"safe":true}"#).unwrap();
assert_eq!(std::fs::read_to_string(victim).unwrap(), "untouched");
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&std::fs::read(state).unwrap()).unwrap()["safe"],
true
);
}
#[test]
fn legacy_worker_config_defaults_missing_browser_pid() {
let value = serde_json::json!({
"id": "legacy",
"profile_id": "profile",
"vless_uri": "vless://example",
"local_port": 1080,
"username": "user",
"password": "password",
"pid": 1,
"xray_pid": 2
});
let config: XrayWorkerConfig = serde_json::from_value(value).unwrap();
assert_eq!(config.created_at, 0);
assert_eq!(config.pid_start_time, None);
assert_eq!(config.xray_pid_start_time, None);
assert!(!config.ready);
assert_eq!(config.browser_pid, None);
assert_eq!(config.browser_pid_start_time, None);
}
}
+5 -2
View File
@@ -6,7 +6,7 @@
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
"devUrl": "http://localhost:12341",
"beforeBuildCommand": "pnpm copy-proxy-binary:release && (test -d ../dist || pnpm build)",
"beforeBuildCommand": "pnpm copy-proxy-binary:release && pnpm build",
"frontendDist": "../dist"
},
"app": {
@@ -30,7 +30,10 @@
"active": true,
"targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"],
"category": "Productivity",
"externalBin": ["binaries/donut-proxy"],
"externalBin": ["binaries/donut-proxy", "binaries/xray"],
"resources": {
"binaries/xray-LICENSE.txt": "licenses/Xray-core-LICENSE.txt"
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",
+275
View File
@@ -156,6 +156,281 @@ async fn test_sidecar_reports_build_version() -> Result<(), Box<dyn std::error::
Ok(())
}
struct XrayChainCleanup {
outer_id: Option<String>,
xray_id: Option<String>,
}
impl XrayChainCleanup {
fn new() -> Self {
Self {
outer_id: None,
xray_id: None,
}
}
async fn cleanup(&mut self) {
if let Some(id) = self.outer_id.take() {
let _ = donutbrowser_lib::proxy_runner::stop_proxy_process(&id).await;
}
if let Some(id) = self.xray_id.take() {
let _ = donutbrowser_lib::xray_worker_runner::stop_xray_worker(&id).await;
}
}
}
impl Drop for XrayChainCleanup {
fn drop(&mut self) {
fn terminate_process_tree(pid: u32) {
#[cfg(unix)]
{
let _ = std::process::Command::new("kill")
.args(["-TERM", &format!("-{pid}")])
.output();
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = std::process::Command::new("taskkill")
.args(["/T", "/F", "/PID", &pid.to_string()])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
}
if let Some(id) = self.outer_id.take() {
if let Some(config) = donutbrowser_lib::proxy_storage::get_proxy_config(&id) {
if let Some(pid) = config.pid {
terminate_process_tree(pid);
}
}
donutbrowser_lib::proxy_storage::delete_proxy_config(&id);
}
if let Some(id) = self.xray_id.take() {
if let Some(config) = donutbrowser_lib::xray_worker_storage::get_xray_worker_config(&id) {
if let Some(pid) = config.pid {
terminate_process_tree(pid);
}
}
donutbrowser_lib::xray_worker_storage::delete_xray_worker_config(&id);
}
}
}
struct EnvironmentRestore {
name: &'static str,
previous: Option<std::ffi::OsString>,
}
impl EnvironmentRestore {
fn set_path(name: &'static str, value: &std::path::Path) -> Self {
let previous = std::env::var_os(name);
std::env::set_var(name, value);
Self { name, previous }
}
}
impl Drop for EnvironmentRestore {
fn drop(&mut self) {
if let Some(previous) = &self.previous {
std::env::set_var(self.name, previous);
} else {
std::env::remove_var(self.name);
}
}
}
/// End-to-end VLESS + XTLS Vision + REALITY test.
///
/// This is intentionally opt-in because it requires a live official Xray-core
/// server. Set `DONUT_E2E_VLESS_URI`; optionally set
/// `DONUT_E2E_XRAY_TARGET_URL` to a controlled plain-HTTP endpoint. When the
/// VLESS endpoint itself is loopback, the test supplies an isolated local HTTP
/// target automatically.
#[tokio::test]
#[serial]
async fn test_xray_reality_chain_and_local_traffic_monitoring(
) -> Result<(), Box<dyn std::error::Error>> {
let Some(vless_uri) = std::env::var("DONUT_E2E_VLESS_URI")
.ok()
.filter(|value| !value.is_empty())
else {
println!("skipping Xray-core integration test: DONUT_E2E_VLESS_URI is not set");
return Ok(());
};
let parsed = donutbrowser_lib::xray::parse_vless_uri(&vless_uri)?;
let endpoint_is_loopback = parsed.config.address == "localhost"
|| parsed
.config
.address
.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_loopback());
let mut local_target = None;
let target_url = if let Ok(url) = std::env::var("DONUT_E2E_XRAY_TARGET_URL") {
url
} else if endpoint_is_loopback {
let (port, server) = start_mock_http_server("XRAY-REALITY-INTEGRATION-OK").await;
local_target = Some(server);
format!("http://127.0.0.1:{port}/xray-reality")
} else {
"http://httpbin.org/anything".to_string()
};
let target = url::Url::parse(&target_url)?;
if target.scheme() != "http" {
return Err(
"DONUT_E2E_XRAY_TARGET_URL must use plain HTTP for exact payload accounting".into(),
);
}
let target_host = target.host_str().ok_or("Xray target URL has no host")?;
let target_port = target
.port_or_known_default()
.ok_or("Xray target URL has no port")?;
let host_header = if target_port == 80 {
target_host.to_string()
} else {
format!("{target_host}:{target_port}")
};
let isolated_root = tempfile::tempdir()?;
let _data_root = EnvironmentRestore::set_path("DONUTBROWSER_DATA_ROOT", isolated_root.path());
let _binary_path = setup_test().await.map_err(|error| error.to_string())?;
let profile_id = format!("xray-integration-{}", uuid::Uuid::new_v4());
let payload = b"donut-xray-monitor-payload";
let mut cleanup = XrayChainCleanup::new();
let (first_worker, concurrent_worker) = tokio::join!(
donutbrowser_lib::xray_worker_runner::start_xray_worker(Some(&profile_id), &vless_uri),
donutbrowser_lib::xray_worker_runner::start_xray_worker(Some(&profile_id), &vless_uri),
);
let xray_worker = first_worker?;
let concurrent_worker = concurrent_worker?;
assert_eq!(
concurrent_worker.id, xray_worker.id,
"concurrent starts for one profile must reuse one Xray worker"
);
cleanup.xray_id = Some(xray_worker.id.clone());
let upstream_url = format!(
"socks5://{}:{}@127.0.0.1:{}",
urlencoding::encode(&xray_worker.username),
urlencoding::encode(&xray_worker.password),
xray_worker.local_port
);
let mut outer = donutbrowser_lib::proxy_runner::start_proxy_process_with_profile(
Some(upstream_url),
None,
Some(profile_id.clone()),
Vec::new(),
None,
false,
Some("http".to_string()),
)
.await?;
cleanup.outer_id = Some(outer.id.clone());
outer.browser_pid = Some(std::process::id());
assert!(
donutbrowser_lib::proxy_storage::update_proxy_config(&outer),
"outer traffic-monitor config should remain writable"
);
let local_port = outer.local_port.ok_or("outer proxy has no local port")?;
let request = format!(
"POST {target_url} HTTP/1.1\r\nHost: {host_header}\r\nContent-Type: \
application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
payload.len()
);
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
stream.write_all(request.as_bytes()).await?;
stream.write_all(payload).await?;
let mut response = Vec::new();
tokio::time::timeout(Duration::from_secs(30), stream.read_to_end(&mut response))
.await
.map_err(|_| "HTTP request through Xray-core timed out")??;
let header_end = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|position| position + 4)
.ok_or("proxy returned an invalid HTTP response")?;
let status_line = String::from_utf8_lossy(&response[..header_end])
.lines()
.next()
.unwrap_or_default()
.to_string();
if !status_line.contains(" 200 ") {
return Err(
format!(
"HTTP request through Xray-core failed ({status_line}): {}",
String::from_utf8_lossy(&response[header_end..])
)
.into(),
);
}
let response_body = &response[header_end..];
if endpoint_is_loopback && std::env::var_os("DONUT_E2E_XRAY_TARGET_URL").is_none() {
assert_eq!(response_body, b"XRAY-REALITY-INTEGRATION-OK");
} else {
assert!(
!response_body.is_empty(),
"controlled Xray target returned an empty body"
);
}
let stats = tokio::time::timeout(Duration::from_secs(10), async {
loop {
if let Some(stats) = donutbrowser_lib::traffic_stats::load_traffic_stats(&profile_id) {
if stats.total_requests > 0 {
break stats;
}
}
sleep(Duration::from_millis(100)).await;
}
})
.await
.map_err(|_| "traffic monitor did not flush Xray session statistics")?;
assert_eq!(stats.total_requests, 1);
assert_eq!(stats.total_bytes_sent, payload.len() as u64);
assert_eq!(stats.total_bytes_received, response_body.len() as u64);
let domain = stats
.domains
.get(target_host)
.ok_or("traffic monitor did not attribute the target domain")?;
assert_eq!(domain.request_count, 1);
assert_eq!(domain.bytes_sent, payload.len() as u64);
assert_eq!(domain.bytes_received, response_body.len() as u64);
let outer_id = outer.id.clone();
let outer_pid = outer.pid;
let xray_id = xray_worker.id.clone();
let supervisor_pid = xray_worker.pid;
let xray_pid = xray_worker.xray_pid;
cleanup.cleanup().await;
if let Some(server) = local_target.take() {
server.abort();
}
assert!(donutbrowser_lib::proxy_storage::get_proxy_config(&outer_id).is_none());
assert!(donutbrowser_lib::xray_worker_storage::get_xray_worker_config(&xray_id).is_none());
assert!(!donutbrowser_lib::xray_worker_storage::xray_runtime_config_path(&xray_id).exists());
for pid in [outer_pid, supervisor_pid, xray_pid].into_iter().flatten() {
for _ in 0..20 {
if !donutbrowser_lib::proxy_storage::is_process_running(pid) {
break;
}
sleep(Duration::from_millis(100)).await;
}
assert!(
!donutbrowser_lib::proxy_storage::is_process_running(pid),
"worker process {pid} survived cleanup"
);
}
Ok(())
}
/// Test starting a local proxy without upstream proxy (DIRECT)
#[tokio::test]
#[serial]
+232 -62
View File
@@ -3,8 +3,9 @@
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuArrowLeft, LuExternalLink, LuSearch } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -12,6 +13,11 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Input } from "@/components/ui/input";
import { StepTransition } from "@/components/ui/step-transition";
import licenses from "@/generated/licenses.json";
import xraySource from "@/generated/xray-source.json";
import { launchDonutClone } from "@/lib/donut-physics";
import { Logo } from "./icons/logo";
import { RippleButton } from "./ui/ripple";
@@ -28,6 +34,8 @@ interface SystemInfo {
portable: boolean;
}
type AboutView = "about" | "licenses";
// Flywheel: each click adds spin; past this speed the donut escapes the
// dialog and bounces around the window (shared physics with the rail egg).
const SPIN_PER_CLICK = 540; // deg/s
@@ -39,6 +47,8 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
const reducedMotion = useReducedMotion();
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
const [logoFlown, setLogoFlown] = useState(false);
const [view, setView] = useState<AboutView>("about");
const [licenseQuery, setLicenseQuery] = useState("");
const logoRef = useRef<HTMLButtonElement>(null);
const rotationRef = useRef(0);
@@ -46,6 +56,7 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
const rafRef = useRef(0);
const lastTimeRef = useRef(0);
const cancelLaunchRef = useRef<(() => void) | null>(null);
const restoreLicensesButtonFocusRef = useRef(false);
useEffect(() => {
if (!isOpen) return;
@@ -111,7 +122,7 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
}
}, [reducedMotion, logoFlown, spinFrame]);
const handleClose = useCallback(() => {
const resetLogo = useCallback(() => {
stopSpin();
cancelLaunchRef.current?.();
cancelLaunchRef.current = null;
@@ -121,8 +132,47 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
logoRef.current.style.visibility = "";
}
setLogoFlown(false);
}, [stopSpin]);
const handleClose = useCallback(() => {
resetLogo();
setView("about");
setLicenseQuery("");
restoreLicensesButtonFocusRef.current = false;
onClose();
}, [stopSpin, onClose]);
}, [onClose, resetLogo]);
const handleLicensesButtonRef = useCallback(
(node: HTMLButtonElement | null) => {
if (node && restoreLicensesButtonFocusRef.current) {
restoreLicensesButtonFocusRef.current = false;
node.focus();
}
},
[],
);
const handleOpenLicenses = useCallback(() => {
resetLogo();
setLicenseQuery("");
setView("licenses");
}, [resetLogo]);
const handleBackToAbout = useCallback(() => {
restoreLicensesButtonFocusRef.current = true;
setLicenseQuery("");
setView("about");
}, []);
const filteredLicenses = useMemo(() => {
const query = licenseQuery.trim().toLocaleLowerCase();
if (!query) return licenses;
return licenses.filter(
({ name, license }) =>
name.toLocaleLowerCase().includes(query) ||
license.toLocaleLowerCase().includes(query),
);
}, [licenseQuery]);
useEffect(() => {
return () => {
@@ -132,72 +182,192 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
}, []);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("about.title")}</DialogTitle>
</DialogHeader>
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) handleClose();
}}
>
<DialogContent
className={
view === "licenses"
? "flex h-[min(80dvh,40rem)] max-w-lg flex-col overflow-hidden"
: "max-w-sm"
}
>
<StepTransition
transitionKey={view}
direction={view === "licenses" ? 1 : -1}
className={
view === "licenses"
? "flex min-h-0 flex-1 flex-col gap-4"
: "grid gap-4"
}
>
{view === "about" ? (
<>
<DialogHeader>
<DialogTitle>{t("about.title")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-3 py-4">
<button
ref={logoRef}
type="button"
aria-label={t("header.donutLogo")}
onClick={handleLogoClick}
className="grid size-16 cursor-pointer place-items-center rounded-full bg-transparent text-foreground select-none will-change-transform"
style={logoFlown ? { visibility: "hidden" } : undefined}
>
<Logo className="size-14" />
</button>
<div className="flex flex-col items-center gap-3 py-4">
<button
ref={logoRef}
type="button"
aria-label={t("header.donutLogo")}
onClick={handleLogoClick}
className="grid size-16 cursor-pointer place-items-center rounded-full bg-transparent text-foreground select-none will-change-transform"
style={logoFlown ? { visibility: "hidden" } : undefined}
>
<Logo className="size-14" />
</button>
<div className="text-center">
<p className="text-lg font-semibold">Donut Browser</p>
{systemInfo && (
<>
<p className="text-sm text-muted-foreground">
{t("about.version", { version: systemInfo.app_version })}
{systemInfo.portable && (
<span className="ml-1.5 rounded border border-border bg-muted px-1 py-px text-[10px] align-middle">
{t("about.portableBadge")}
</span>
<div className="text-center">
<p className="text-lg font-semibold">Donut Browser</p>
{systemInfo && (
<>
<p className="text-sm text-muted-foreground">
{t("about.version", {
version: systemInfo.app_version,
})}
{systemInfo.portable && (
<span className="ml-1.5 rounded border border-border bg-muted px-1 py-px text-[10px] align-middle">
{t("about.portableBadge")}
</span>
)}
</p>
<p className="text-xs text-muted-foreground">
{systemInfo.os} {systemInfo.arch}
</p>
</>
)}
</div>
<p className="text-center text-xs text-muted-foreground">
{t("about.licenseNotice")}
</p>
<p className="text-xs text-muted-foreground">
{systemInfo.os} {systemInfo.arch}
</p>
</>
)}
</div>
<p className="text-center text-xs text-muted-foreground">
{t("about.licenseNotice")}
</p>
<div className="flex flex-wrap justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void openUrl("https://donutbrowser.com")}
>
{t("about.website")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
void openUrl("https://github.com/zhom/donutbrowser")
}
>
{t("about.github")}
</Button>
<Button
ref={handleLicensesButtonRef}
variant="outline"
size="sm"
onClick={handleOpenLicenses}
>
{t("about.licenses")}
</Button>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void openUrl("https://donutbrowser.com")}
>
{t("about.website")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
void openUrl("https://github.com/zhom/donutbrowser")
}
>
GitHub
</Button>
</div>
</div>
<div className="flex justify-end">
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.close")}
</RippleButton>
</div>
</>
) : (
<>
<DialogHeader className="flex-row items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="-ml-2 size-8"
aria-label={t("common.buttons.back")}
onClick={handleBackToAbout}
>
<LuArrowLeft aria-hidden="true" />
</Button>
<DialogTitle>{t("about.licenses")}</DialogTitle>
</DialogHeader>
<div className="flex justify-end">
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.close")}
</RippleButton>
</div>
<div className="relative shrink-0">
<LuSearch
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
autoFocus
type="search"
value={licenseQuery}
onChange={(event) => {
setLicenseQuery(event.target.value);
}}
aria-label={t("about.searchLicenses")}
placeholder={t("about.searchLicenses")}
className="pl-9"
/>
</div>
<FadingScrollArea
role="region"
tabIndex={0}
aria-label={t("about.licenses")}
className="-mx-2 min-h-0 flex-1 rounded-md px-2 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{filteredLicenses.length > 0 ? (
<ul>
{filteredLicenses.map(({ name, license }) => (
<li
key={`${name}-${license}`}
className="grid grid-cols-[minmax(0,1fr)_minmax(7rem,45%)] items-baseline gap-3 border-b border-border/60 py-2.5 last:border-b-0"
>
{name === "Xray-core" ? (
<button
type="button"
onClick={() => void openUrl(xraySource.sourceUrl)}
aria-label={t("about.openSource", { name })}
title={t("about.openSource", { name })}
className="flex min-w-0 items-center gap-1.5 rounded-sm text-left text-sm font-medium text-foreground underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 active:scale-[0.98] motion-reduce:transform-none"
>
<span className="truncate">{name}</span>
<LuExternalLink
aria-hidden="true"
className="size-3.5 shrink-0 text-muted-foreground"
/>
</button>
) : (
<span
className="truncate text-sm font-medium text-foreground"
title={name}
>
{name}
</span>
)}
<code className="break-words text-right text-xs text-muted-foreground">
{license}
</code>
</li>
))}
</ul>
) : (
<p
role="status"
className="py-12 text-center text-sm text-muted-foreground"
>
{t("about.noMatchingLicenses")}
</p>
)}
</FadingScrollArea>
</>
)}
</StepTransition>
</DialogContent>
</Dialog>
);
+3 -3
View File
@@ -222,7 +222,7 @@ export function AccountPage({
<AnimatedTabsContent value="account" className="mt-4">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="grid size-12 shrink-0 place-items-center rounded-full bg-accent text-foreground">
<div className="grid size-12 shrink-0 place-items-center rounded-full bg-accent text-accent-foreground">
<LuUser className="size-6" />
</div>
<div className="min-w-0 flex-1">
@@ -305,7 +305,7 @@ export function AccountPage({
user &&
getEntitlements(user).browserAutomation &&
user.isPrimaryDevice === false && (
<p className="text-xs text-warning">
<p className="text-xs text-warning-text">
{t("account.automationPrimaryOnly")}
</p>
)}
@@ -314,7 +314,7 @@ export function AccountPage({
getEntitlements(user).browserAutomation &&
user.isPrimaryDevice === true &&
(user.deviceCount ?? 1) > 1 && (
<p className="text-xs text-success">
<p className="text-xs text-success-text">
{t("account.automationActiveHere")}
</p>
)}
+1 -1
View File
@@ -63,7 +63,7 @@ export function BandwidthMiniChart({
type="button"
onClick={onClick}
className={cn(
"relative flex w-full min-w-0 cursor-pointer items-center gap-1.5 rounded border-none bg-transparent px-2 transition-colors hover:bg-accent/50",
"relative flex w-full min-w-0 cursor-pointer items-center gap-1.5 rounded border-none bg-transparent px-2 transition-colors hover:bg-muted",
className,
)}
>
@@ -98,7 +98,7 @@ export function ConsistencyWarningDialog({
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuTriangleAlert className="size-5 text-warning" />
<LuTriangleAlert className="size-5 text-warning-text" />
{t("consistencyWarning.title")}
</DialogTitle>
</DialogHeader>
+3 -3
View File
@@ -407,7 +407,7 @@ export function CookieCopyDialog({
>
{p.name}
{runningProfiles.has(p.id) && (
<span className="text-xs text-destructive">
<span className="text-xs text-destructive-text">
{t("cookies.copy.running")}
</span>
)}
@@ -451,7 +451,7 @@ export function CookieCopyDialog({
<div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : error ? (
<div className="rounded-md bg-destructive/10 p-4 text-center text-destructive">
<div className="rounded-md bg-destructive/10 p-4 text-center text-destructive-text">
{error}
</div>
) : filteredDomains.length === 0 ? (
@@ -547,7 +547,7 @@ function DomainRow({
return (
<AnimatedDisclosureItem>
<div className="flex items-center gap-2 rounded p-2 hover:bg-accent/50">
<div className="flex items-center gap-2 rounded p-2 hover:bg-muted">
<Checkbox
checked={isAllSelected || isPartial}
onCheckedChange={() => {
+2 -2
View File
@@ -487,7 +487,7 @@ export function CookieManagementDialog({
{importResult && (
<div className="space-y-4">
<div className="rounded-lg bg-success/10 p-4">
<div className="font-medium text-success">
<div className="font-medium text-success-text">
{t("cookies.management.importedSuccess", {
imported: importResult.cookies_imported,
replaced: importResult.cookies_replaced,
@@ -634,7 +634,7 @@ function ExportDomainRow({
return (
<AnimatedDisclosureItem>
<div className="flex items-center gap-2 rounded p-1.5 hover:bg-accent/50">
<div className="flex items-center gap-2 rounded p-1.5 hover:bg-muted">
<Checkbox
checked={isAllSelected || isPartial}
onCheckedChange={() => {
+1 -1
View File
@@ -93,7 +93,7 @@ export function CreateGroupDialog({
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive-text">
{error}
</div>
)}
+5 -5
View File
@@ -741,7 +741,7 @@ export function CreateProfileDialog({
autoComplete="new-password"
/>
{passwordError && (
<p className="text-sm text-destructive">
<p className="text-sm text-destructive-text">
{passwordError}
</p>
)}
@@ -764,7 +764,7 @@ export function CreateProfileDialog({
)}
{!isLoadingReleaseTypes && releaseTypesError && (
<div className="flex items-center gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="flex-1 text-sm text-destructive">
<p className="flex-1 text-sm text-destructive-text">
{releaseTypesError}
</p>
<RippleButton
@@ -783,7 +783,7 @@ export function CreateProfileDialog({
!releaseTypesError &&
!getBestAvailableVersion("wayfern") && (
<div className="flex items-center gap-3 rounded-md border border-warning/50 bg-warning/10 p-3">
<p className="text-sm text-warning">
<p className="text-sm text-warning-text">
{t("createProfile.platformUnavailable", {
browser: "Wayfern",
})}
@@ -910,7 +910,7 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
releaseTypesError && (
<div className="flex items-center gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="flex-1 text-sm text-destructive">
<p className="flex-1 text-sm text-destructive-text">
{releaseTypesError}
</p>
<RippleButton
@@ -1272,7 +1272,7 @@ export function CreateProfileDialog({
)}
{!isLoadingReleaseTypes && releaseTypesError && (
<div className="flex items-center gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="flex-1 text-sm text-destructive">
<p className="flex-1 text-sm text-destructive-text">
{releaseTypesError}
</p>
<RippleButton
+1 -1
View File
@@ -330,7 +330,7 @@ export function UnifiedToast(props: ToastProps) {
})}`}
</p>
{progress.failed_count > 0 && (
<p className="mt-0.5 text-xs text-destructive">
<p className="mt-0.5 text-xs text-destructive-text">
{t("toasts.progress.filesFailed", {
count: progress.failed_count,
})}
+3 -3
View File
@@ -98,7 +98,7 @@ function DataTableActionBarAction({
variant="secondary"
size={size}
className={cn(
"gap-1.5 border border-secondary bg-secondary/50 hover:bg-secondary/70 [&>svg]:size-3.5",
"gap-1.5 border border-secondary bg-secondary text-secondary-foreground hover:bg-secondary hover:text-secondary-foreground [&>svg]:size-3.5",
size === "icon" ? "size-7" : "h-7",
className,
)}
@@ -120,7 +120,7 @@ function DataTableActionBarAction({
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent
sideOffset={6}
className="border bg-accent font-semibold text-foreground dark:bg-card [&>span]:hidden"
className="border bg-accent font-semibold text-accent-foreground [&>span]:hidden"
>
<p>{tooltip}</p>
</TooltipContent>
@@ -161,7 +161,7 @@ function DataTableActionBarSelection<TData>({
</TooltipTrigger>
<TooltipContent
sideOffset={10}
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-card [&>span]:hidden"
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-accent-foreground [&>span]:hidden"
>
<p>{t("dataTableActionBar.clearSelection")}</p>
<kbd className="rounded border bg-background px-1.5 py-px font-mono text-[0.7rem] font-normal text-foreground shadow-xs select-none">
+2 -2
View File
@@ -165,7 +165,7 @@ export function DeleteGroupDialog({
<RadioGroupItem value="delete" id="delete" />
<Label
htmlFor="delete"
className="text-sm text-destructive"
className="text-sm text-destructive-text"
>
{t("groups.deleteAlongWithGroup")}
</Label>
@@ -184,7 +184,7 @@ export function DeleteGroupDialog({
)}
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive-text">
{error}
</div>
)}
+1 -1
View File
@@ -103,7 +103,7 @@ export function EditGroupDialog({
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive-text">
{error}
</div>
)}
@@ -160,7 +160,7 @@ export function ExtensionGroupAssignmentDialog({
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive-text">
{error}
</div>
)}
@@ -1147,13 +1147,13 @@ export function ExtensionManagementDialog({
disabled={limitedMode}
>
<span>{t("extensions.extensionsTab")}</span>
<span className="text-xs text-muted-foreground tabular-nums">
<span className="text-xs tabular-nums">
{extensions.length}
</span>
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="groups" disabled={limitedMode}>
<span>{t("extensions.groupsTab")}</span>
<span className="text-xs text-muted-foreground tabular-nums">
<span className="text-xs tabular-nums">
{extensionGroups.length}
</span>
</AnimatedTabsTrigger>
@@ -1696,7 +1696,7 @@ export function ExtensionManagementDialog({
href={editingExtension.homepage_url}
target="_blank"
rel="noopener noreferrer"
className="flex min-w-0 items-center gap-1 text-primary hover:underline"
className="flex min-w-0 items-center gap-1 text-primary-text hover:underline"
>
<span className="truncate">
{editingExtension.homepage_url}
@@ -1849,7 +1849,7 @@ export function ExtensionManagementDialog({
tooltip={t("common.buttons.delete")}
variant="destructive"
size="icon"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
className="border-destructive bg-destructive hover:bg-destructive"
onClick={() => {
setBulkExtDeleteOpen(true);
}}
@@ -1875,7 +1875,7 @@ export function ExtensionManagementDialog({
tooltip={t("common.buttons.delete")}
variant="destructive"
size="icon"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
className="border-destructive bg-destructive hover:bg-destructive"
onClick={() => {
setBulkGroupDeleteOpen(true);
}}
+1 -1
View File
@@ -198,7 +198,7 @@ export function GroupAssignmentDialog({
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive-text">
{error}
</div>
)}
+10 -4
View File
@@ -569,9 +569,15 @@ export function GroupManagementDialog({
<div className="@container flex min-h-0 w-full flex-1 flex-col">
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
<div className="inline-flex h-7 items-center justify-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium whitespace-nowrap text-foreground">
<div
data-slot="group-summary-pill"
className="inline-flex h-7 items-center justify-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium whitespace-nowrap text-accent-foreground"
>
<span>{t("groups.pageTitle")}</span>
<span className="text-xs text-muted-foreground tabular-nums">
<span
data-slot="group-summary-count"
className="text-xs tabular-nums"
>
{groups.length}
</span>
</div>
@@ -591,7 +597,7 @@ export function GroupManagementDialog({
</div>
{error && (
<div className="mt-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="mt-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive-text">
{error}
</div>
)}
@@ -708,7 +714,7 @@ export function GroupManagementDialog({
onClick={() => setBulkDeleteOpen(true)}
size="icon"
variant="destructive"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
className="border-destructive bg-destructive hover:bg-destructive"
>
<LuTrash2 />
</DataTableActionBarAction>
+3 -3
View File
@@ -221,7 +221,7 @@ const HomeHeader = ({
behavior: "smooth",
});
}}
className="absolute top-1/2 left-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground"
className="absolute top-1/2 left-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
<LuChevronLeft className="size-3" />
</button>
@@ -295,7 +295,7 @@ const HomeHeader = ({
behavior: "smooth",
});
}}
className="absolute top-1/2 right-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground"
className="absolute top-1/2 right-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
<LuChevronRight className="size-3" />
</button>
@@ -323,7 +323,7 @@ const HomeHeader = ({
onClick={() => {
onSearchQueryChange("");
}}
className="absolute top-1/2 right-1.5 -translate-y-1/2 transform rounded-sm p-0.5 transition-colors hover:bg-accent"
className="absolute top-1/2 right-1.5 -translate-y-1/2 transform rounded-sm p-0.5 transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label={t("header.clearSearch")}
>
<LuX className="size-3.5 text-muted-foreground hover:text-foreground" />
+4 -3
View File
@@ -847,10 +847,11 @@ export function ImportProfileDialog({
<span
className={cn(
"shrink-0 text-xs font-medium",
item.status === "imported" && "text-success",
item.status === "imported" && "text-success-text",
item.status === "skipped" &&
"text-muted-foreground",
item.status === "failed" && "text-destructive",
item.status === "failed" &&
"text-destructive-text",
)}
>
{item.status === "imported" &&
@@ -864,7 +865,7 @@ export function ImportProfileDialog({
{item.name || item.source_path}
</span>
{item.error && (
<span className="min-w-0 flex-1 truncate text-xs text-destructive">
<span className="min-w-0 flex-1 truncate text-xs text-destructive-text">
{translateBackendError(t, new Error(item.error))}
</span>
)}
+2 -2
View File
@@ -535,7 +535,7 @@ export function IntegrationsDialog({
<p className="text-xs text-muted-foreground">
{t("integrations.mcpEnableDescription")}
{!termsAccepted && (
<span className="ml-1 text-warning">
<span className="ml-1 text-warning-text">
{t("integrations.mcpAcceptTermsFirst")}
</span>
)}
@@ -622,7 +622,7 @@ export function IntegrationsDialog({
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-destructive"
className="size-8 text-muted-foreground hover:text-destructive-text"
disabled={busy}
onClick={() =>
void handleRemoveAgent(agent)
+1 -1
View File
@@ -205,7 +205,7 @@ export function PermissionDialog({
<div className="space-y-4">
{!isCurrentPermissionGranted && (
<div className="rounded-lg bg-warning/10 p-3">
<p className="text-sm text-warning">
<p className="text-sm text-warning-text">
{permissionType === "microphone"
? t("permissionDialog.notGrantedMicrophone")
: t("permissionDialog.notGrantedCamera")}
+10 -10
View File
@@ -370,7 +370,7 @@ function ExtCell({
<button
type="button"
disabled={isSaving}
className="flex h-7 w-full items-center gap-1.5 rounded px-1.5 text-left text-xs text-muted-foreground transition-colors duration-100 hover:bg-accent/50 hover:text-foreground disabled:opacity-50"
className="flex h-7 w-full items-center gap-1.5 rounded px-1.5 text-left text-xs text-muted-foreground transition-colors duration-100 hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
>
<LuPuzzle className="size-3 shrink-0" />
<span className="flex-1 truncate" title={label}>
@@ -457,7 +457,7 @@ function DnsCell({
type="button"
data-onborda="dns-blocklist"
disabled={isSaving}
className="flex h-7 w-full items-center gap-1.5 rounded px-1.5 text-left text-xs text-muted-foreground transition-colors duration-100 hover:bg-accent/50 hover:text-foreground disabled:opacity-50"
className="flex h-7 w-full items-center gap-1.5 rounded px-1.5 text-left text-xs text-muted-foreground transition-colors duration-100 hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
title={
level
? meta.t("profiles.table.dnsLevel", { level })
@@ -681,7 +681,7 @@ const TagsCell = React.memo<{
"flex h-6 w-full cursor-pointer items-center gap-1 overflow-hidden rounded border-none bg-transparent px-2 py-1",
isDisabled
? "cursor-not-allowed opacity-60"
: "cursor-pointer hover:bg-accent/50",
: "cursor-pointer hover:bg-muted",
)}
onClick={() => {
if (!isDisabled) setOpenTagsEditorFor(profile.id);
@@ -894,7 +894,7 @@ const ProxyCellTrigger = React.memo<{
"flex max-w-full min-w-0 items-center gap-2 rounded px-2 py-1",
isDisabled
? "pointer-events-none cursor-not-allowed opacity-60"
: "cursor-pointer hover:bg-accent/50",
: "cursor-pointer hover:bg-muted",
)}
>
{vpnBadge && (
@@ -1043,7 +1043,7 @@ const NoteCell = React.memo<{
"flex min-h-6 w-full min-w-0 items-center rounded border-none bg-transparent px-2 py-1 text-left",
isDisabled
? "cursor-not-allowed opacity-60"
: "cursor-pointer hover:bg-accent/50",
: "cursor-pointer hover:bg-muted",
)}
onClick={() => {
if (!isDisabled) {
@@ -2362,7 +2362,7 @@ export function ProfilesDataTable({
<Tooltip>
<TooltipTrigger asChild>
<span>
<LuTriangleAlert className="size-4 text-warning" />
<LuTriangleAlert className="size-4 text-warning-text" />
</span>
</TooltipTrigger>
<TooltipContent>
@@ -2390,7 +2390,7 @@ export function ProfilesDataTable({
canLaunch && "cursor-pointer",
isFollower && "border-accent",
isRunning &&
"bg-destructive/10 text-destructive hover:bg-destructive/20",
"bg-destructive/10 text-destructive-text hover:bg-destructive/20",
)}
onClick={() =>
isRunning
@@ -2611,7 +2611,7 @@ export function ProfilesDataTable({
"mr-auto h-6 max-w-full min-w-0 overflow-hidden rounded border-none bg-transparent px-2 py-1 text-left",
isCrossOsBlocked
? "cursor-not-allowed opacity-60"
: "cursor-pointer hover:bg-accent/50",
: "cursor-pointer hover:bg-muted",
)}
onClick={() => {
if (isCrossOsBlocked) return;
@@ -3325,7 +3325,7 @@ export function ProfilesDataTable({
title={crossOsTitle}
style={{ height: `${ROW_HEIGHT}px` }}
className={cn(
"overflow-visible border-0! hover:bg-accent/50",
"overflow-visible border-0! hover:bg-muted",
rowIsCrossOs && "opacity-60",
)}
>
@@ -3514,7 +3514,7 @@ export function ProfilesDataTable({
onClick={onBulkDelete}
size="icon"
variant="destructive"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
className="border-destructive bg-destructive hover:bg-destructive"
>
<LuTrash2 />
</DataTableActionBarAction>
+30 -24
View File
@@ -870,7 +870,7 @@ function ProfileInfoLayout({
type="button"
aria-label={t("common.buttons.close")}
onClick={onClose}
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors duration-100 hover:bg-accent/50 hover:text-foreground"
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors duration-100 hover:bg-accent hover:text-accent-foreground"
>
<LuX className="size-3.5" />
</button>
@@ -893,7 +893,7 @@ function ProfileInfoLayout({
"flex h-7 items-center gap-2 rounded-md px-2 text-left text-xs transition-colors duration-100",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<span className="shrink-0">{it.icon}</span>
@@ -913,7 +913,7 @@ function ProfileInfoLayout({
type="button"
onClick={deleteAction.onClick}
disabled={deleteAction.disabled}
className="flex h-7 items-center gap-2 rounded-md px-2 text-xs text-destructive transition-colors duration-100 hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
className="flex h-7 items-center gap-2 rounded-md px-2 text-xs text-destructive-text transition-colors duration-100 hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
>
<LuTrash2 className="size-3.5 shrink-0" />
<span className="flex-1 text-left">
@@ -1181,8 +1181,8 @@ function _SectionAction({
className={cn(
"flex h-9 items-center gap-2 rounded-md px-3 text-left text-xs transition-colors",
destructive
? "text-destructive hover:bg-destructive/10"
: "hover:bg-accent",
? "text-destructive-text hover:bg-destructive/10"
: "hover:bg-accent hover:text-accent-foreground",
"disabled:pointer-events-none disabled:opacity-50",
)}
>
@@ -1254,11 +1254,11 @@ function LaunchHookEditor({
className="font-mono text-xs"
/>
{showInvalidHint && (
<p className="text-xs text-warning">
<p className="text-xs text-warning-text">
{t("profileInfo.launchHook.invalidUrlHint")}
</p>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
{error && <p className="text-xs text-destructive-text">{error}</p>}
<div className="flex items-center gap-2">
<Button
size="sm"
@@ -1360,11 +1360,13 @@ function SyncSectionInline({
{t(`profileInfo.syncStatusValue.${syncStatus.status}`)}
</p>
{syncStatus.error && (
<p className="mt-1 text-xs text-destructive">{syncStatus.error}</p>
<p className="mt-1 text-xs text-destructive-text">
{syncStatus.error}
</p>
)}
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
{error && <p className="text-xs text-destructive-text">{error}</p>}
</div>
);
}
@@ -1500,7 +1502,7 @@ function NetworkSectionInline({
</Select>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
{error && <p className="text-xs text-destructive-text">{error}</p>}
</div>
);
}
@@ -1604,7 +1606,7 @@ function ExtensionsSectionInline({
</SelectContent>
</Select>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
{error && <p className="text-xs text-destructive-text">{error}</p>}
</div>
);
}
@@ -1812,7 +1814,7 @@ function CookiesSectionInline({
</ul>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
{error && <p className="text-xs text-destructive-text">{error}</p>}
</>
)}
</div>
@@ -1928,8 +1930,10 @@ function FingerprintSectionInline({
profileBrowser={profile.browser}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
{success && !error && <p className="text-xs text-success">{success}</p>}
{error && <p className="text-xs text-destructive-text">{error}</p>}
{success && !error && (
<p className="text-xs text-success-text">{success}</p>
)}
<div className="mt-3 flex items-center gap-2 border-t border-border pt-3">
<Button
@@ -2102,7 +2106,7 @@ function SecuritySectionInline({
}}
className={cn(
"h-7 flex-1 rounded-md border px-2 text-xs transition-colors",
"border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
"border-border text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{t("profilePassword.modes.validate")}
@@ -2117,7 +2121,7 @@ function SecuritySectionInline({
"h-7 flex-1 rounded-md border px-2 text-xs transition-colors",
mode === "change"
? "border-transparent bg-accent text-accent-foreground"
: "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
: "border-border text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{t("profilePassword.modes.change")}
@@ -2131,8 +2135,8 @@ function SecuritySectionInline({
className={cn(
"h-7 flex-1 rounded-md border px-2 text-xs transition-colors",
mode === "remove"
? "border-transparent bg-destructive/10 text-destructive"
: "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
? "border-transparent bg-destructive/10 text-destructive-text"
: "border-border text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{t("profilePassword.modes.remove")}
@@ -2182,8 +2186,10 @@ function SecuritySectionInline({
)}
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
{success && !error && <p className="text-xs text-success">{success}</p>}
{error && <p className="text-xs text-destructive-text">{error}</p>}
{success && !error && (
<p className="text-xs text-success-text">{success}</p>
)}
{isRunning && (
<p className="text-xs text-muted-foreground">
@@ -2395,7 +2401,7 @@ export function ProfileDnsBlocklistDialog({
href="https://github.com/hagezi/dns-blocklists"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
className="text-primary-text hover:underline"
>
{t("common.buttons.moreInfo")}
</a>
@@ -2408,8 +2414,8 @@ export function ProfileDnsBlocklistDialog({
onClick={() => setLevel(option.value)}
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
level === option.value
? "bg-primary/10 text-primary border border-primary/30"
: "hover:bg-accent border border-transparent"
? "border border-primary/30 bg-primary/10 text-primary-text"
: "border border-transparent hover:bg-accent hover:text-accent-foreground"
}`}
>
{option.label}
@@ -2531,7 +2537,7 @@ export function ProfileBypassRulesDialog({
onClick={() => {
handleRemoveRule(rule);
}}
className="shrink-0 text-muted-foreground transition-colors hover:text-destructive"
className="shrink-0 text-muted-foreground transition-colors hover:text-destructive-text"
>
<LuX className="size-3.5" />
</button>
+2 -2
View File
@@ -203,7 +203,7 @@ export function ProfilePasswordDialog({
<div className="flex flex-col gap-3">
{(mode === "set" || mode === "change") && (
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">
<p className="font-medium text-warning">
<p className="font-medium text-warning-text">
{t("profilePassword.warnings.forgetWarningTitle")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
@@ -212,7 +212,7 @@ export function ProfilePasswordDialog({
</div>
)}
{lockoutSecondsRemaining != null && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive-text">
{t("backendErrors.lockedOut", {
duration: formatLockoutDuration(t, lockoutSecondsRemaining),
})}
+1 -1
View File
@@ -267,7 +267,7 @@ export function ProfileSyncDialog({
{syncMode === "Encrypted" &&
!hasE2ePassword &&
userChangedMode && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive-text">
{t("sync.mode.noPasswordWarning")}
</div>
)}
+1 -1
View File
@@ -299,7 +299,7 @@ export function ProxyAssignmentDialog({
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive-text">
{error}
</div>
)}
+4 -4
View File
@@ -165,7 +165,7 @@ export function ProxyCheckButton({
<FiCheck className="absolute right-[-4px] bottom-[-6px]" />
</span>
) : result && !result.is_valid ? (
<span className="text-sm text-destructive"></span>
<span className="text-sm text-destructive-text"></span>
) : (
<FiCheck className="size-3" />
)}
@@ -185,10 +185,10 @@ export function ProxyCheckButton({
{[result.city, result.country].filter(Boolean).join(", ") ||
t("proxyCheck.unknownLocation")}
</p>
<p className="text-xs text-primary-foreground/70">
<p className="text-xs text-primary-foreground">
{t("proxyCheck.tooltipIp", { ip: result.ip })}
</p>
<p className="text-xs text-primary-foreground/70">
<p className="text-xs text-primary-foreground">
{t("proxyCheck.tooltipChecked", {
time: formatRelativeTime(result.timestamp),
})}
@@ -197,7 +197,7 @@ export function ProxyCheckButton({
) : result && !result.is_valid ? (
<div>
<p>{t("proxyCheck.tooltipFailedTitle")}</p>
<p className="text-xs text-primary-foreground/70">
<p className="text-xs text-primary-foreground">
{t("proxyCheck.tooltipFailed", {
time: formatRelativeTime(result.timestamp),
})}
+179 -82
View File
@@ -21,6 +21,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { translateBackendError } from "@/lib/backend-errors";
import type { StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -32,6 +33,7 @@ interface ProxyFormData {
port: number;
username: string;
password: string;
vless_uri: string;
}
interface ProxyFormDialogProps {
@@ -47,8 +49,38 @@ const DEFAULT_FORM: ProxyFormData = {
port: 8080,
username: "",
password: "",
vless_uri: "",
};
interface VlessEndpoint {
host: string;
port: number;
}
function parseVlessEndpoint(uri: string): VlessEndpoint | null {
try {
const parsed = new URL(uri.trim());
const port = Number.parseInt(parsed.port, 10);
if (
parsed.protocol !== "vless:" ||
!parsed.hostname ||
!Number.isInteger(port) ||
port < 1 ||
port > 65535
) {
return null;
}
const host =
parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]")
? parsed.hostname.slice(1, -1)
: parsed.hostname;
return { host, port };
} catch {
return null;
}
}
export function ProxyFormDialog({
isOpen,
onClose,
@@ -79,6 +111,7 @@ export function ProxyFormDialog({
port: editingProxy.proxy_settings.port,
username: editingProxy.proxy_settings.username ?? "",
password: editingProxy.proxy_settings.password ?? "",
vless_uri: editingProxy.proxy_settings.vless_uri ?? "",
});
}, [editingProxy, isOpen, resetForm]);
@@ -88,7 +121,20 @@ export function ProxyFormDialog({
return;
}
if (!form.host.trim() || !form.port) {
const isVless = form.proxy_type === "vless";
const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null;
if (isVless && !form.vless_uri.trim()) {
toast.error(t("proxies.form.vlessUriRequired"));
return;
}
if (isVless && !vlessEndpoint) {
toast.error(t("proxies.form.vlessUriInvalid"));
return;
}
if (!isVless && (!form.host.trim() || !form.port)) {
toast.error(t("proxies.form.hostPortRequired"));
return;
}
@@ -107,10 +153,11 @@ export function ProxyFormDialog({
name: form.name.trim(),
proxySettings: {
proxy_type: form.proxy_type,
host: form.host.trim(),
port: form.port,
username: form.username.trim() || undefined,
password: form.password.trim() || undefined,
host: vlessEndpoint?.host ?? form.host.trim(),
port: vlessEndpoint?.port ?? form.port,
username: isVless ? undefined : form.username.trim() || undefined,
password: isVless ? undefined : form.password.trim() || undefined,
vless_uri: isVless ? form.vless_uri.trim() : undefined,
},
};
@@ -144,13 +191,19 @@ export function ProxyFormDialog({
}
}, [isSubmitting, onClose]);
const isVless = form.proxy_type === "vless";
const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null;
const hasInvalidVlessUri =
isVless && form.vless_uri.trim().length > 0 && !vlessEndpoint;
const isFormValid =
form.name.trim() &&
form.host.trim() &&
form.port > 0 &&
form.port <= 65535 &&
(form.proxy_type !== "ss" ||
(form.username.trim() && form.password.trim()));
(isVless
? vlessEndpoint !== null
: form.host.trim() &&
form.port > 0 &&
form.port <= 65535 &&
(form.proxy_type !== "ss" ||
(form.username.trim() && form.password.trim())));
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
@@ -176,7 +229,7 @@ export function ProxyFormDialog({
</div>
<div className="grid gap-2">
<Label>{t("proxies.form.type")}</Label>
<Label htmlFor="proxy-type">{t("proxies.form.type")}</Label>
<Select
value={form.proxy_type}
onValueChange={(value) => {
@@ -184,91 +237,135 @@ export function ProxyFormDialog({
}}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectTrigger id="proxy-type">
<SelectValue placeholder={t("proxies.form.selectType")} />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5", "ss"].map((type) => (
<SelectItem key={type} value={type}>
{type === "ss" ? "Shadowsocks" : type.toUpperCase()}
</SelectItem>
))}
{["http", "https", "socks4", "socks5", "ss", "vless"].map(
(type) => (
<SelectItem key={type} value={type}>
{type === "ss"
? "Shadowsocks"
: type === "vless"
? t("proxies.form.vlessType")
: type.toUpperCase()}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
{isVless ? (
<div className="grid gap-2">
<Label htmlFor="proxy-host">{t("proxies.form.host")}</Label>
<Input
id="proxy-host"
value={form.host}
onChange={(e) => {
setForm({ ...form, host: e.target.value });
}}
placeholder={t("proxies.form.hostPlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">{t("proxies.form.port")}</Label>
<Input
id="proxy-port"
type="number"
value={form.port}
onChange={(e) => {
setForm({
...form,
port: Number.parseInt(e.target.value, 10) || 0,
});
}}
placeholder={t("proxies.form.portPlaceholder")}
min="1"
max="65535"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 @sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="proxy-username">
{form.proxy_type === "ss"
? t("proxies.form.cipher")
: t("proxies.form.username")}
<Label htmlFor="proxy-vless-uri">
{t("proxies.form.vlessUri")}
</Label>
<Input
id="proxy-username"
value={form.username}
<Textarea
id="proxy-vless-uri"
value={form.vless_uri}
onChange={(e) => {
setForm({ ...form, username: e.target.value });
setForm({ ...form, vless_uri: e.target.value });
}}
placeholder={
form.proxy_type === "ss"
? t("proxies.form.cipherPlaceholder")
: t("proxies.form.usernamePlaceholder")
placeholder={t("proxies.form.vlessUriPlaceholder")}
disabled={isSubmitting}
aria-invalid={hasInvalidVlessUri}
aria-describedby="proxy-vless-uri-help"
autoCapitalize="none"
autoComplete="off"
spellCheck={false}
className="min-h-24 resize-y font-mono text-xs leading-relaxed"
/>
<p
id="proxy-vless-uri-help"
className={
hasInvalidVlessUri
? "text-xs text-destructive-text"
: "text-xs text-muted-foreground"
}
disabled={isSubmitting}
/>
role={hasInvalidVlessUri ? "alert" : undefined}
>
{hasInvalidVlessUri
? t("proxies.form.vlessUriInvalid")
: t("proxies.form.vlessUriHint")}
</p>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-host">{t("proxies.form.host")}</Label>
<Input
id="proxy-host"
value={form.host}
onChange={(e) => {
setForm({ ...form, host: e.target.value });
}}
placeholder={t("proxies.form.hostPlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-password">
{t("proxies.form.password")}
</Label>
<Input
id="proxy-password"
type="password"
value={form.password}
onChange={(e) => {
setForm({ ...form, password: e.target.value });
}}
placeholder={t("proxies.form.passwordPlaceholder")}
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">{t("proxies.form.port")}</Label>
<Input
id="proxy-port"
type="number"
value={form.port}
onChange={(e) => {
setForm({
...form,
port: Number.parseInt(e.target.value, 10) || 0,
});
}}
placeholder={t("proxies.form.portPlaceholder")}
min="1"
max="65535"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 @sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="proxy-username">
{form.proxy_type === "ss"
? t("proxies.form.cipher")
: t("proxies.form.username")}
</Label>
<Input
id="proxy-username"
value={form.username}
onChange={(e) => {
setForm({ ...form, username: e.target.value });
}}
placeholder={
form.proxy_type === "ss"
? t("proxies.form.cipherPlaceholder")
: t("proxies.form.usernamePlaceholder")
}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-password">
{t("proxies.form.password")}
</Label>
<Input
id="proxy-password"
type="password"
value={form.password}
onChange={(e) => {
setForm({ ...form, password: e.target.value });
}}
placeholder={t("proxies.form.passwordPlaceholder")}
disabled={isSubmitting}
/>
</div>
</div>
</>
)}
</div>
<DialogFooter>
+5 -5
View File
@@ -420,7 +420,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
key={`${proxy.original_line}-${i}`}
className="rounded bg-muted/30 p-2 font-mono text-xs break-all"
>
<span className="text-primary">
<span className="text-primary-text">
{proxy.proxy_type}://
</span>
{proxy.username && (
@@ -487,7 +487,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
<span className="text-sm">
{t("proxies.importDialog.imported")}
</span>
<span className="text-sm font-medium text-success">
<span className="text-sm font-medium text-success-text">
{importResult.imported_count}
</span>
</div>
@@ -496,7 +496,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
<span className="text-sm">
{t("proxies.importDialog.skippedDuplicates")}
</span>
<span className="text-sm font-medium text-warning">
<span className="text-sm font-medium text-warning-text">
{importResult.skipped_count}
</span>
</div>
@@ -506,7 +506,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
<span className="text-sm">
{t("proxies.importDialog.errors")}
</span>
<span className="text-sm font-medium text-destructive">
<span className="text-sm font-medium text-destructive-text">
{importResult.errors.length}
</span>
</div>
@@ -521,7 +521,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
{importResult.errors.map((error, i) => (
<div
key={`error-${i}`}
className="text-xs text-destructive"
className="text-xs text-destructive-text"
>
{error}
</div>
+4 -4
View File
@@ -1111,13 +1111,13 @@ export function ProxyManagementDialog({
<AnimatedTabsList>
<AnimatedTabsTrigger value="proxies">
<span>{t("proxies.management.tabProxies")}</span>
<span className="text-xs text-muted-foreground tabular-nums">
<span className="text-xs tabular-nums">
{storedProxies.length}
</span>
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="vpns">
<span>{t("proxies.management.tabVpns")}</span>
<span className="text-xs text-muted-foreground tabular-nums">
<span className="text-xs tabular-nums">
{vpnConfigs.length}
</span>
</AnimatedTabsTrigger>
@@ -1531,7 +1531,7 @@ export function ProxyManagementDialog({
}}
size="icon"
variant="destructive"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
className="border-destructive bg-destructive hover:bg-destructive"
>
<LuTrash2 />
</DataTableActionBarAction>
@@ -1554,7 +1554,7 @@ export function ProxyManagementDialog({
}}
size="icon"
variant="destructive"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
className="border-destructive bg-destructive hover:bg-destructive"
>
<LuTrash2 />
</DataTableActionBarAction>
+8 -8
View File
@@ -319,8 +319,8 @@ export function RailNav({
className={cn(
"relative grid size-7 shrink-0 cursor-pointer place-items-center rounded-md transition-colors duration-100",
active
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{active && <ActiveIndicator />}
@@ -347,8 +347,8 @@ export function RailNav({
className={cn(
"grid size-7 shrink-0 cursor-pointer place-items-center rounded-md transition-colors duration-100",
moreOpen
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<GoKebabHorizontal className="size-3.5" />
@@ -369,8 +369,8 @@ export function RailNav({
className={cn(
"relative grid size-7 shrink-0 cursor-pointer place-items-center rounded-md transition-colors duration-100",
currentPage === "settings"
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{currentPage === "settings" && <ActiveIndicator />}
@@ -404,7 +404,7 @@ export function RailNav({
setMoreOpen(false);
onNavigate(page);
}}
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-100 hover:bg-accent"
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-100 hover:bg-accent hover:text-accent-foreground"
>
<span className="grid size-5 shrink-0 place-items-center rounded bg-muted text-muted-foreground">
<Icon className="size-3" />
@@ -426,7 +426,7 @@ export function RailNav({
setMoreOpen(false);
onOpenAbout();
}}
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-100 hover:bg-accent"
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-100 hover:bg-accent hover:text-accent-foreground"
>
<span className="grid size-5 shrink-0 place-items-center rounded bg-muted text-muted-foreground">
<LuInfo className="size-3" />
+22 -33
View File
@@ -51,8 +51,11 @@ import { useLanguage } from "@/hooks/use-language";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import {
applyThemeColors,
clearThemeColors,
getThemeByColors,
getThemeById,
normalizeThemeColors,
THEME_VARIABLES,
THEMES,
withThemeTransition,
@@ -238,19 +241,13 @@ export function SettingsDialog({
const applyCustomTheme = useCallback((vars: Record<string, string>) => {
withThemeTransition(() => {
const root = document.documentElement;
Object.entries(vars).forEach(([k, v]) => {
root.style.setProperty(k, v, "important");
});
applyThemeColors(vars);
});
}, []);
const clearCustomTheme = useCallback(() => {
withThemeTransition(() => {
const root = document.documentElement;
THEME_VARIABLES.forEach(({ key }) => {
root.style.removeProperty(key as string);
});
clearThemeColors();
});
}, []);
@@ -267,7 +264,7 @@ export function SettingsDialog({
custom_theme:
appSettings.custom_theme &&
Object.keys(appSettings.custom_theme).length > 0
? appSettings.custom_theme
? normalizeThemeColors(appSettings.custom_theme)
: tokyoNightTheme.colors,
};
setSettings(merged);
@@ -490,24 +487,15 @@ export function SettingsDialog({
if (settings.theme === "custom") {
if (Object.keys(customThemeState.colors).length > 0) {
try {
const root = document.documentElement;
// Clear any previous custom vars first
THEME_VARIABLES.forEach(({ key }) => {
root.style.removeProperty(key as string);
});
Object.entries(customThemeState.colors).forEach(([k, v]) => {
root.style.setProperty(k, v, "important");
});
clearThemeColors();
applyThemeColors(customThemeState.colors);
} catch {
/* empty */
}
}
} else {
try {
const root = document.documentElement;
THEME_VARIABLES.forEach(({ key }) => {
root.style.removeProperty(key as string);
});
clearThemeColors();
} catch {
/* empty */
}
@@ -569,6 +557,7 @@ export function SettingsDialog({
applyCustomTheme(originalSettings.custom_theme);
} else {
clearCustomTheme();
setTheme(originalSettings.theme);
}
// Reset custom theme state to original
@@ -587,6 +576,7 @@ export function SettingsDialog({
applyCustomTheme,
clearCustomTheme,
onClose,
setTheme,
]);
// Only clear custom theme when switching away from custom, don't apply live
@@ -596,8 +586,9 @@ export function SettingsDialog({
useEffect(() => {
if (hasLoadedSettings && settings.theme !== "custom") {
clearCustomTheme();
setTheme(settings.theme);
}
}, [hasLoadedSettings, settings.theme, clearCustomTheme]);
}, [hasLoadedSettings, settings.theme, clearCustomTheme, setTheme]);
// Unmount-safe theme restore: rail navigation unmounts this dialog without
// calling handleClose, which used to leave the theme vars wiped for the
@@ -606,21 +597,17 @@ export function SettingsDialog({
return () => {
const s = originalSettingsRef.current;
if (!hasLoadedSettingsRef.current || !s) return;
const root = document.documentElement;
if (s.theme === "custom" && s.custom_theme) {
Object.entries(s.custom_theme).forEach(([k, v]) => {
root.style.setProperty(k, v, "important");
});
applyThemeColors(s.custom_theme);
} else {
// A non-custom persisted theme (light/dark/system) uses the
// stylesheet palette — strip any leftover inline custom vars so a
// just-saved switch away from custom isn't reverted on unmount.
THEME_VARIABLES.forEach(({ key }) => {
root.style.removeProperty(key as string);
});
clearThemeColors();
setTheme(s.theme);
}
};
}, []);
}, [setTheme]);
useEffect(() => {
if (isOpen) {
@@ -1177,7 +1164,9 @@ export function SettingsDialog({
}}
/>
{e2eError && (
<p className="text-sm text-destructive">{e2eError}</p>
<p className="text-sm text-destructive-text">
{e2eError}
</p>
)}
<LoadingButton
variant="default"
@@ -1249,7 +1238,7 @@ export function SettingsDialog({
// customer reads like a billing error, so swap in a
// subscription-active badge instead.
<div className="space-y-1">
<p className="text-sm font-medium text-success">
<p className="text-sm font-medium text-success-text">
{t("settings.commercial.subscriptionActive", {
plan: cloudUser.plan,
})}
@@ -1272,7 +1261,7 @@ export function SettingsDialog({
</div>
) : (
<div className="space-y-1">
<p className="text-sm font-medium text-warning">
<p className="text-sm font-medium text-warning-text">
{t("settings.commercial.trialExpired")}
</p>
<p className="text-xs text-muted-foreground">
+1 -1
View File
@@ -168,7 +168,7 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
}}
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/50 p-3 transition-colors hover:bg-card"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary-text">
<Icon className="size-4" />
</div>
<div className="min-w-0 flex-1 truncate text-sm font-medium">
+1 -1
View File
@@ -412,7 +412,7 @@ export function SyncConfigDialog({
onClick={() => {
setShowToken(!showToken);
}}
className="absolute top-1/2 right-3 -translate-y-1/2 transform rounded-sm p-1 transition-colors hover:bg-accent"
className="absolute top-1/2 right-3 -translate-y-1/2 transform rounded-sm p-1 transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label={
showToken
? t("common.aria.hideToken")
+2 -2
View File
@@ -155,7 +155,7 @@ export function SyncFollowerDialog({
return (
<div
key={profile.id}
className="flex cursor-pointer items-center gap-3 rounded-md p-2 hover:bg-accent"
className="flex cursor-pointer items-center gap-3 rounded-md p-2 hover:bg-accent hover:text-accent-foreground"
onClick={() => {
handleToggle(
profile.id,
@@ -182,7 +182,7 @@ export function SyncFollowerDialog({
<TooltipTrigger asChild>
<Badge
variant="outline"
className="shrink-0 border-warning/50 px-1.5 py-0 text-[10px] text-warning"
className="shrink-0 border-warning/50 px-1.5 py-0 text-[10px] text-warning-text"
>
{t("profiles.synchronizer.flakyBadge")}
</Badge>
+3 -4
View File
@@ -59,9 +59,7 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const setTheme = useCallback((newTheme: string) => {
setThemeState(newTheme);
withThemeTransition(() => {
if (newTheme === "custom") {
applyClassToHtml("dark");
} else {
if (newTheme !== "custom") {
applyClassToHtml(newTheme);
}
});
@@ -77,7 +75,6 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
if (themeValue === "custom") {
setThemeState("custom");
applyClassToHtml("dark");
if (
settings.custom_theme &&
Object.keys(settings.custom_theme).length > 0
@@ -87,6 +84,8 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
} catch (error) {
console.warn("Failed to apply custom theme variables:", error);
}
} else {
applyClassToHtml("dark");
}
} else if (
themeValue === "light" ||
+1 -1
View File
@@ -10,7 +10,7 @@ const alertVariants = cva(
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
"bg-card text-destructive-text *:data-[slot=alert-description]:text-destructive-text [&>svg]:text-current",
},
},
defaultVariants: {
+3 -1
View File
@@ -122,7 +122,8 @@ function AnimatedTabsTrigger({
className={cn(
"relative inline-flex h-7 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap transition-colors duration-150",
"text-muted-foreground hover:text-foreground",
isActive && "text-foreground",
isActive && !showIndicator && "text-foreground",
showIndicator && "text-accent-foreground hover:text-accent-foreground",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
"disabled:pointer-events-none disabled:opacity-50",
className,
@@ -132,6 +133,7 @@ function AnimatedTabsTrigger({
{showIndicator && (
<motion.span
layoutId={`animated-tabs-indicator-${indicatorId}`}
data-slot="animated-tabs-indicator"
className="pointer-events-none absolute inset-0 -z-10 rounded-md bg-accent"
transition={{ type: "spring", stiffness: 360, damping: 32 }}
/>
+3 -3
View File
@@ -10,11 +10,11 @@ const badgeVariants = cva(
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
"border-transparent bg-primary text-primary-foreground [a&]:hover:shadow-sm",
secondary:
"border-transparent bg-secondary text-secondary-foreground dark:bg-secondary/60 [a&]:hover:bg-secondary/90",
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:shadow-sm",
destructive:
"border-transparent bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
"border-transparent bg-destructive text-destructive-foreground focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 [a&]:hover:shadow-sm",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
+6 -8
View File
@@ -9,17 +9,15 @@ const buttonVariants = cva(
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
default: "bg-primary text-primary-foreground shadow-xs hover:shadow-md",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
"bg-destructive text-destructive-foreground shadow-xs hover:shadow-md focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
"bg-secondary text-secondary-foreground shadow-xs hover:shadow-md",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary-text underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
+1 -1
View File
@@ -153,7 +153,7 @@ function CommandItem({
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
"relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[selected=true]:[&_svg:not([class*='text-'])]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
+1 -1
View File
@@ -270,7 +270,7 @@ function DialogContent({
>
{children}
{!hideClose && dismissible && (
<DialogPrimitive.Close className="absolute top-4 right-4 cursor-pointer rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<DialogPrimitive.Close className="absolute top-4 right-4 cursor-pointer rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<RxCross2 />
<span className="sr-only">{t("common.buttons.close")}</span>
</DialogPrimitive.Close>
+1 -1
View File
@@ -74,7 +74,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
"relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 data-[variant=destructive]:text-destructive-text data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive-text focus:[&_svg:not([class*='text-'])]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive-text!",
className,
)}
{...props}
+1 -1
View File
@@ -28,7 +28,7 @@ const RadioGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square size-4 cursor-pointer rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"aspect-square size-4 cursor-pointer rounded-full border border-primary-text text-primary-text ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
+5 -7
View File
@@ -11,15 +11,13 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: "bg-primary text-primary-foreground hover:shadow-sm",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
"bg-destructive text-destructive-foreground hover:shadow-sm focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
"border border-input bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30",
secondary: "bg-secondary text-secondary-foreground hover:shadow-sm",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-4 py-2 has-[>svg]:px-3",
+1 -1
View File
@@ -107,7 +107,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 focus:[&_svg:not([class*='text-'])]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
+4 -4
View File
@@ -109,9 +109,9 @@ export function VpnCheckButton({
{isCurrentlyChecking ? (
<span className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
) : result?.is_valid ? (
<FiCheck className="size-3 text-success" />
<FiCheck className="size-3 text-success-text" />
) : result && !result.is_valid ? (
<span className="text-sm text-destructive"></span>
<span className="text-sm text-destructive-text"></span>
) : (
<FiCheck className="size-3" />
)}
@@ -125,7 +125,7 @@ export function VpnCheckButton({
) : result?.is_valid ? (
<div className="space-y-1">
<p>{t("vpnCheck.tooltipValid")}</p>
<p className="text-xs text-primary-foreground/70">
<p className="text-xs text-primary-foreground">
{t("vpnCheck.tooltipChecked", {
time: formatRelativeTime(result.timestamp),
})}
@@ -134,7 +134,7 @@ export function VpnCheckButton({
) : result && !result.is_valid ? (
<div>
<p>{t("vpnCheck.tooltipInvalid")}</p>
<p className="text-xs text-primary-foreground/70">
<p className="text-xs text-primary-foreground">
{t("vpnCheck.tooltipChecked", {
time: formatRelativeTime(result.timestamp),
})}
+5 -5
View File
@@ -282,7 +282,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
{step === "vpn-preview" && vpnPreview && (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-lg bg-muted/30 p-4">
<LuShield className="size-8 text-primary" />
<LuShield className="size-8 text-primary-text" />
<div>
<div className="font-medium">
{t("vpns.import.configurationLabel", {
@@ -332,9 +332,9 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
>
{vpnImportResult.success ? (
<div className="flex items-center gap-3">
<LuShield className="size-8 text-success" />
<LuShield className="size-8 text-success-text" />
<div>
<div className="font-medium text-success">
<div className="font-medium text-success-text">
{t("vpns.import.importedSuccess")}
</div>
<div className="text-sm text-muted-foreground">
@@ -344,10 +344,10 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
</div>
) : (
<div className="space-y-2">
<div className="font-medium text-destructive">
<div className="font-medium text-destructive-text">
{t("vpns.import.importFailed")}
</div>
<div className="text-sm text-destructive">
<div className="text-sm text-destructive-text">
{vpnImportResult.error}
</div>
</div>
+1 -1
View File
@@ -70,7 +70,7 @@ export function WayfernTermsDialog({
href="https://wayfern.com/tos"
target="_blank"
rel="noopener noreferrer"
className="block text-sm font-medium text-primary hover:underline"
className="block text-sm font-medium text-primary-text hover:underline"
>
https://wayfern.com/tos
</a>
+4 -4
View File
@@ -316,7 +316,7 @@ export function WelcomeDialog({
<dl className="flex flex-col gap-3">
<div className="flex items-start gap-3 rounded-lg border p-4">
<LuHeart className="mt-0.5 size-4 shrink-0 text-success" />
<LuHeart className="mt-0.5 size-4 shrink-0 text-success-text" />
<div className="flex flex-col gap-0.5 text-left">
<dt className="text-base/7 font-medium text-foreground sm:text-sm/6">
{t("welcome.license.personalTitle")}
@@ -331,7 +331,7 @@ export function WelcomeDialog({
<div className="flex flex-col gap-0.5 text-left">
<dt className="flex flex-wrap items-center gap-2 text-base/7 font-medium text-foreground sm:text-sm/6">
{t("welcome.license.commercialTitle")}
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary-text">
{t("welcome.license.trialBadge")}
</span>
</dt>
@@ -389,7 +389,7 @@ export function WelcomeDialog({
initial={{ opacity: 0, scale: 0.85, rotate: -8 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={panelTransition}
className="flex size-12 items-center justify-center gap-1.5 rounded-full bg-primary/10 text-primary"
className="flex size-12 items-center justify-center gap-1.5 rounded-full bg-primary/10 text-primary-text"
aria-hidden="true"
>
<LuMic className="size-4 shrink-0" />
@@ -445,7 +445,7 @@ export function WelcomeDialog({
{setup.phase === "error" ? (
<>
<div className="flex flex-col items-center gap-2">
<h2 className="flex items-center justify-center gap-2 text-2xl font-semibold tracking-tight text-balance text-destructive">
<h2 className="flex items-center justify-center gap-2 text-2xl font-semibold tracking-tight text-balance text-destructive-text">
<LuTriangleAlert className="size-5 shrink-0" />
{t("welcome.ready.errorTitle")}
</h2>
+1 -1
View File
@@ -167,7 +167,7 @@ export function WindowDragArea() {
onClick={() => {
void handleClose();
}}
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive/90 hover:text-destructive-foreground"
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
aria-label={t("common.buttons.close")}
>
<svg
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
{
"sourceUrl": "https://github.com/XTLS/Xray-core/tree/v26.3.27"
}
+19 -4
View File
@@ -459,7 +459,13 @@
"hostPortRequired": "Host and port are required",
"ssCipherRequired": "Cipher and password are required for Shadowsocks",
"selectType": "Select proxy type",
"saveFailed": "Failed to save proxy: {{error}}"
"saveFailed": "Failed to save proxy: {{error}}",
"vlessType": "VLESS · Vision · REALITY",
"vlessUri": "VLESS URI",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "Requires XTLS Vision and REALITY.",
"vlessUriRequired": "Enter a VLESS URI.",
"vlessUriInvalid": "Enter a valid VLESS URI with a host and port."
},
"types": {
"http": "HTTP",
@@ -1841,7 +1847,11 @@
"mcpConfigurationUnavailable": "The MCP server configuration is unavailable. Restart the server and try again.",
"mcpAgentUnknown": "This MCP client is not supported.",
"mcpAgentInstallFailed": "Could not add Donut Browser to the MCP client: {{detail}}",
"mcpAgentRemoveFailed": "Could not remove Donut Browser from the MCP client: {{detail}}"
"mcpAgentRemoveFailed": "Could not remove Donut Browser from the MCP client: {{detail}}",
"vlessConfigInvalid": "The VLESS URI is invalid.",
"xrayUnavailable": "Xray-core is unavailable on this system.",
"xrayUnsupportedOs": "VLESS requires macOS 12 or newer.",
"xrayStartFailed": "Xray-core could not start."
},
"rail": {
"profiles": "Profiles",
@@ -2068,8 +2078,13 @@
"title": "About",
"version": "Version {{version}}",
"portableBadge": "portable",
"licenseNotice": "Open-source anti-detect browser, licensed under AGPL-3.0.",
"website": "Website"
"licenseNotice": "Open-source anti-detect browser.",
"website": "Website",
"github": "GitHub",
"licenses": "Licenses",
"searchLicenses": "Search licenses…",
"noMatchingLicenses": "No matching licenses.",
"openSource": "View {{name}} source code"
},
"clearOnClose": {
"label": "Clear data on close",
+19 -4
View File
@@ -459,7 +459,13 @@
"hostPortRequired": "Host y puerto son obligatorios",
"ssCipherRequired": "Para Shadowsocks se requieren cifrado y contraseña",
"selectType": "Selecciona el tipo de proxy",
"saveFailed": "Error al guardar el proxy: {{error}}"
"saveFailed": "Error al guardar el proxy: {{error}}",
"vlessType": "VLESS · Vision · REALITY",
"vlessUri": "URI de VLESS",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "Requiere XTLS Vision y REALITY.",
"vlessUriRequired": "Introduce un URI de VLESS.",
"vlessUriInvalid": "Introduce un URI de VLESS válido con host y puerto."
},
"types": {
"http": "HTTP",
@@ -1841,7 +1847,11 @@
"mcpConfigurationUnavailable": "La configuración del servidor MCP no está disponible. Reinicia el servidor e inténtalo de nuevo.",
"mcpAgentUnknown": "Este cliente MCP no es compatible.",
"mcpAgentInstallFailed": "No se pudo añadir Donut Browser al cliente MCP: {{detail}}",
"mcpAgentRemoveFailed": "No se pudo eliminar Donut Browser del cliente MCP: {{detail}}"
"mcpAgentRemoveFailed": "No se pudo eliminar Donut Browser del cliente MCP: {{detail}}",
"vlessConfigInvalid": "El URI de VLESS no es válido.",
"xrayUnavailable": "Xray-core no está disponible en este sistema.",
"xrayUnsupportedOs": "VLESS requiere macOS 12 o posterior.",
"xrayStartFailed": "No se pudo iniciar Xray-core."
},
"rail": {
"profiles": "Perfiles",
@@ -2068,8 +2078,13 @@
"title": "Acerca de",
"version": "Versión {{version}}",
"portableBadge": "portable",
"licenseNotice": "Navegador anti-detección de código abierto, con licencia AGPL-3.0.",
"website": "Sitio web"
"licenseNotice": "Navegador anti-detección de código abierto.",
"website": "Sitio web",
"github": "GitHub",
"licenses": "Licencias",
"searchLicenses": "Buscar licencias…",
"noMatchingLicenses": "No hay licencias que coincidan.",
"openSource": "Ver el código fuente de {{name}}"
},
"clearOnClose": {
"label": "Borrar datos al cerrar",
+19 -4
View File
@@ -459,7 +459,13 @@
"hostPortRequired": "Hôte et port sont requis",
"ssCipherRequired": "Le chiffrement et le mot de passe sont requis pour Shadowsocks",
"selectType": "Sélectionnez le type de proxy",
"saveFailed": "Échec de la sauvegarde du proxy : {{error}}"
"saveFailed": "Échec de la sauvegarde du proxy : {{error}}",
"vlessType": "VLESS · Vision · REALITY",
"vlessUri": "URI VLESS",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "Nécessite XTLS Vision et REALITY.",
"vlessUriRequired": "Saisissez une URI VLESS.",
"vlessUriInvalid": "Saisissez une URI VLESS valide avec un hôte et un port."
},
"types": {
"http": "HTTP",
@@ -1841,7 +1847,11 @@
"mcpConfigurationUnavailable": "La configuration du serveur MCP est indisponible. Redémarrez le serveur et réessayez.",
"mcpAgentUnknown": "Ce client MCP nest pas pris en charge.",
"mcpAgentInstallFailed": "Impossible dajouter Donut Browser au client MCP : {{detail}}",
"mcpAgentRemoveFailed": "Impossible de retirer Donut Browser du client MCP : {{detail}}"
"mcpAgentRemoveFailed": "Impossible de retirer Donut Browser du client MCP : {{detail}}",
"vlessConfigInvalid": "LURI VLESS nest pas valide.",
"xrayUnavailable": "Xray-core nest pas disponible sur ce système.",
"xrayUnsupportedOs": "VLESS nécessite macOS 12 ou une version ultérieure.",
"xrayStartFailed": "Impossible de démarrer Xray-core."
},
"rail": {
"profiles": "Profils",
@@ -2068,8 +2078,13 @@
"title": "À propos",
"version": "Version {{version}}",
"portableBadge": "portable",
"licenseNotice": "Navigateur anti-détection open source, sous licence AGPL-3.0.",
"website": "Site web"
"licenseNotice": "Navigateur anti-détection open source.",
"website": "Site web",
"github": "GitHub",
"licenses": "Licences",
"searchLicenses": "Rechercher des licences…",
"noMatchingLicenses": "Aucune licence correspondante.",
"openSource": "Voir le code source de {{name}}"
},
"clearOnClose": {
"label": "Effacer les données à la fermeture",

Some files were not shown because too many files have changed in this diff Show More