mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-02 01:08:41 +02:00
feat: xray support
This commit is contained in:
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
Generated
+38
-31
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+21
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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!(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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(_) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(¶meters, "security", "reality")?;
|
||||
require_value(¶meters, "flow", VlessFlow::Vision.as_str())?;
|
||||
optional_value(¶meters, "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(¶meters, "headerType", "none")?;
|
||||
|
||||
let server_name = required_parameter(¶meters, "sni")?.to_string();
|
||||
let public_key = required_parameter(¶meters, "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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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),
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"sourceUrl": "https://github.com/XTLS/Xray-core/tree/v26.3.27"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 n’est pas pris en charge.",
|
||||
"mcpAgentInstallFailed": "Impossible d’ajouter 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": "L’URI VLESS n’est pas valide.",
|
||||
"xrayUnavailable": "Xray-core n’est 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
Reference in New Issue
Block a user