diff --git a/.github/workflows/app-e2e.yml b/.github/workflows/app-e2e.yml index 48df920..dba9bd0 100644 --- a/.github/workflows/app-e2e.yml +++ b/.github/workflows/app-e2e.yml @@ -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: | diff --git a/.github/workflows/lint-rs.yml b/.github/workflows/lint-rs.yml index d81f3a9..fbf3eb8 100644 --- a/.github/workflows/lint-rs.yml +++ b/.github/workflows/lint-rs.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bf80cd..f6f0c8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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. diff --git a/.github/workflows/rolling-release.yml b/.github/workflows/rolling-release.yml index af28d70..3246ae9 100644 --- a/.github/workflows/rolling-release.yml +++ b/.github/workflows/rolling-release.yml @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 7573b09..a6db95f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/_typos.toml b/_typos.toml index 61497eb..0e1e5e0 100644 --- a/_typos.toml +++ b/_typos.toml @@ -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", diff --git a/e2e/app/Cargo.lock b/e2e/app/Cargo.lock index b90de46..f39426a 100644 --- a/e2e/app/Cargo.lock +++ b/e2e/app/Cargo.lock @@ -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" diff --git a/e2e/app/Cargo.toml b/e2e/app/Cargo.toml index ddab55c..1ba09dc 100644 --- a/e2e/app/Cargo.toml +++ b/e2e/app/Cargo.toml @@ -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" } diff --git a/e2e/app/src/main.rs b/e2e/app/src/main.rs index d062016..4daaf6a 100644 --- a/e2e/app/src/main.rs +++ b/e2e/app/src/main.rs @@ -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()) }); } diff --git a/e2e/lib/app.mjs b/e2e/lib/app.mjs index 1dc42ed..2cea3b6 100644 --- a/e2e/lib/app.mjs +++ b/e2e/lib/app.mjs @@ -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) { diff --git a/e2e/lib/fixtures.mjs b/e2e/lib/fixtures.mjs index 0109b06..59c6b9d 100644 --- a/e2e/lib/fixtures.mjs +++ b/e2e/lib/fixtures.mjs @@ -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" }; } diff --git a/e2e/run.mjs b/e2e/run.mjs index 8c2de4d..a58ad5a 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -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", }); diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs index be278e7..e7dd75f 100644 --- a/e2e/tests/browser.test.mjs +++ b/e2e/tests/browser.test.mjs @@ -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; diff --git a/e2e/tests/integrations.test.mjs b/e2e/tests/integrations.test.mjs index 8df5842..c741d64 100644 --- a/e2e/tests/integrations.test.mjs +++ b/e2e/tests/integrations.test.mjs @@ -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", { diff --git a/e2e/tests/network.test.mjs b/e2e/tests/network.test.mjs index d0acd88..5966cbf 100644 --- a/e2e/tests/network.test.mjs +++ b/e2e/tests/network.test.mjs @@ -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, diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index e4b6069..5bee11a 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -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" }, + ); + }); +}); diff --git a/package.json b/package.json index eafe0d9..16fa3d3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 000f075..e5bda4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/scripts/generate-licenses.mjs b/scripts/generate-licenses.mjs new file mode 100644 index 0000000..57bb6a8 --- /dev/null +++ b/scripts/generate-licenses.mjs @@ -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(); diff --git a/scripts/generate-licenses.test.mjs b/scripts/generate-licenses.test.mjs new file mode 100644 index 0000000..34bcf0d --- /dev/null +++ b/scripts/generate-licenses.test.mjs @@ -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 }); +}); diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 69d1d0f..6f7e689 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -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() { diff --git a/src-tauri/copy-proxy-binary.mjs b/src-tauri/copy-proxy-binary.mjs index 6f07f9c..8fa2def 100644 --- a/src-tauri/copy-proxy-binary.mjs +++ b/src-tauri/copy-proxy-binary.mjs @@ -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); diff --git a/src-tauri/download-xray.mjs b/src-tauri/download-xray.mjs new file mode 100644 index 0000000..8ef1dfc --- /dev/null +++ b/src-tauri/download-xray.mjs @@ -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); + }); +} diff --git a/src-tauri/download-xray.test.mjs b/src-tauri/download-xray.test.mjs new file mode 100644 index 0000000..c18a8dd --- /dev/null +++ b/src-tauri/download-xray.test.mjs @@ -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/, + ); +}); diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 1789dfc..2dc89ec 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -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!( diff --git a/src-tauri/src/bin/proxy_server.rs b/src-tauri/src/bin/proxy_server.rs index 25f1743..c7e75eb 100644 --- a/src-tauri/src/bin/proxy_server.rs +++ b/src-tauri/src/bin/proxy_server.rs @@ -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::("action") + .expect("action is required"); + let config_path = xray_matches + .get_one::("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::("url") diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index cde8b0c..6d8e063 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -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, pub password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vless_uri: Option, } #[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) diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 94f0303..b6bf56f 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -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>>>, +> = 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, - local_proxy_settings: Option<&ProxySettings>, - ) -> Result> { - 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 { e.into() })?; + let geo_proxy_signature_settings = upstream_proxy.clone(); + + struct XrayLaunchGuard { + worker_id: Option, + 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 { + 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> { + 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> { // 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(); diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index ed23f44..f610ea8 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -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(_) => { diff --git a/src-tauri/src/fingerprint_consistency.rs b/src-tauri/src/fingerprint_consistency.rs index 3e17f25..523b534 100644 --- a/src-tauri/src/fingerprint_consistency.rs +++ b/src-tauri/src/fingerprint_consistency.rs @@ -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 { +/// 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 { 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); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 948b949..448e5fc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 0d98f8f..d9624a5 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -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 { diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index eb99082..2587814 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -32,6 +32,8 @@ pub struct ExportedProxy { pub username: Option, #[serde(skip_serializing_if = "Option::is_none")] pub password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vless_uri: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -49,6 +51,8 @@ pub struct ParsedProxyLine { pub port: u16, pub username: Option, pub password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vless_uri: Option, pub original_line: String, } @@ -418,6 +422,31 @@ impl ProxyManager { Ok(()) } + fn normalize_proxy_settings(mut proxy_settings: ProxySettings) -> Result { + 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 { - 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 { + 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, diff --git a/src-tauri/src/proxy_runner.rs b/src-tauri/src/proxy_runner.rs index 3a7df1c..81e9160 100644 --- a/src-tauri/src/proxy_runner.rs +++ b/src-tauri/src/proxy_runner.rs @@ -50,27 +50,23 @@ fn prune_stale_proxy_logs(temp_dir: &Path, retain: usize) { } } -fn target_binary_name(base_name: &str) -> Option { - 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 #[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(); diff --git a/src-tauri/src/proxy_server.rs b/src-tauri/src/proxy_server.rs index 7ec6e31..7b5c94f 100644 --- a/src-tauri/src/proxy_server.rs +++ b/src-tauri/src/proxy_server.rs @@ -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 { inner: S, - bytes_read: Arc, bytes_written: Arc, + 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>, } impl CountingStream { - 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 AsyncRead for CountingStream { cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { - 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 AsyncWrite for CountingStream { 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(); diff --git a/src-tauri/src/proxy_storage.rs b/src-tauri/src/proxy_storage.rs index 8d5adf2..63e0894 100644 --- a/src-tauri/src/proxy_storage.rs +++ b/src-tauri/src/proxy_storage.rs @@ -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 { 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) -> 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(); diff --git a/src-tauri/src/xray/client.rs b/src-tauri/src/xray/client.rs new file mode 100644 index 0000000..f56736a --- /dev/null +++ b/src-tauri/src/xray/client.rs @@ -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 { + 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 { + 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)); + } +} diff --git a/src-tauri/src/xray/error.rs b/src-tauri/src/xray/error.rs new file mode 100644 index 0000000..b98b910 --- /dev/null +++ b/src-tauri/src/xray/error.rs @@ -0,0 +1,29 @@ +use thiserror::Error; + +pub type XrayResult = Result; + +#[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, +} diff --git a/src-tauri/src/xray/mod.rs b/src-tauri/src/xray/mod.rs new file mode 100644 index 0000000..a7d5ab0 --- /dev/null +++ b/src-tauri/src/xray/mod.rs @@ -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}; diff --git a/src-tauri/src/xray/model.rs b/src-tauri/src/xray/model.rs new file mode 100644 index 0000000..439a4ce --- /dev/null +++ b/src-tauri/src/xray/model.rs @@ -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 { + 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, + 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::().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::(value).is_err()); + } +} diff --git a/src-tauri/src/xray/uri.rs b/src-tauri/src/xray/uri.rs new file mode 100644 index 0000000..e86841e --- /dev/null +++ b/src-tauri/src/xray/uri.rs @@ -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 { + 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 { + 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::() { + 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> { + 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, + 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, + 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, + 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::>() + .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" + ); + } +} diff --git a/src-tauri/src/xray_worker_runner.rs b/src-tauri/src/xray_worker_runner.rs new file mode 100644 index 0000000..67b8445 --- /dev/null +++ b/src-tauri/src/xray_worker_runner.rs @@ -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 { + 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 { + serde_json::json!({ "code": code }).to_string().into() +} + +fn structured_error_with_detail( + code: &str, + detail: impl std::fmt::Display, +) -> Box { + 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 { + version.trim().split('.').next()?.parse().ok() +} + +#[cfg(target_os = "macos")] +fn ensure_supported_macos_version() -> Result<(), Box> { + 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> { + #[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> { + 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> { + 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::>(); + 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> { + 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, + pid_start_time: Option, + 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> { + 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> { + stop_xray_worker_now(id) +} + +pub async fn stop_xray_worker_by_profile_id( + profile_id: &str, +) -> Result> { + let workers = list_xray_worker_configs() + .into_iter() + .filter(|config| config.profile_id.as_deref() == Some(profile_id)) + .collect::>(); + 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> { + 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> { + 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> = 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)); + } +} diff --git a/src-tauri/src/xray_worker_storage.rs b/src-tauri/src/xray_worker_storage.rs new file mode 100644 index 0000000..77e0c81 --- /dev/null +++ b/src-tauri/src/xray_worker_storage.rs @@ -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, + pub vless_uri: String, + pub local_port: u16, + pub username: String, + pub password: String, + #[serde(default)] + pub created_at: u64, + pub pid: Option, + #[serde(default)] + pub pid_start_time: Option, + pub xray_pid: Option, + #[serde(default)] + pub xray_pid_start_time: Option, + #[serde(default)] + pub ready: bool, + #[serde(default)] + pub browser_pid: Option, + #[serde(default)] + pub browser_pid_start_time: Option, +} + +impl XrayWorkerConfig { + pub fn new( + id: String, + profile_id: Option, + 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 { + 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 { + 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> { + 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> { + 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 { + get_xray_worker_config_from_path(&xray_worker_config_path(id)) +} + +pub fn get_xray_worker_config_from_path(path: &Path) -> Option { + 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 { + 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 { + 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::() + ) +} + +#[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::(&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); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a1d54d6..8ef3227 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src-tauri/tests/donut_proxy_integration.rs b/src-tauri/tests/donut_proxy_integration.rs index 7275b3a..356f699 100644 --- a/src-tauri/tests/donut_proxy_integration.rs +++ b/src-tauri/tests/donut_proxy_integration.rs @@ -156,6 +156,281 @@ async fn test_sidecar_reports_build_version() -> Result<(), Box, + xray_id: Option, +} + +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, +} + +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> { + 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::() + .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] diff --git a/src/components/about-dialog.tsx b/src/components/about-dialog.tsx index 35ee159..3181791 100644 --- a/src/components/about-dialog.tsx +++ b/src/components/about-dialog.tsx @@ -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(null); const [logoFlown, setLogoFlown] = useState(false); + const [view, setView] = useState("about"); + const [licenseQuery, setLicenseQuery] = useState(""); const logoRef = useRef(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 ( - - - - {t("about.title")} - + { + if (!open) handleClose(); + }} + > + + + {view === "about" ? ( + <> + + {t("about.title")} + -
- +
+ -
-

Donut Browser

- {systemInfo && ( - <> -

- {t("about.version", { version: systemInfo.app_version })} - {systemInfo.portable && ( - - {t("about.portableBadge")} - +

+

Donut Browser

+ {systemInfo && ( + <> +

+ {t("about.version", { + version: systemInfo.app_version, + })} + {systemInfo.portable && ( + + {t("about.portableBadge")} + + )} +

+

+ {systemInfo.os} {systemInfo.arch} +

+ )} +
+ +

+ {t("about.licenseNotice")}

-

- {systemInfo.os} {systemInfo.arch} -

- - )} -
-

- {t("about.licenseNotice")} -

+
+ + + +
+
-
- - -
-
+
+ + {t("common.buttons.close")} + +
+ + ) : ( + <> + + + {t("about.licenses")} + -
- - {t("common.buttons.close")} - -
+
+
+ + + {filteredLicenses.length > 0 ? ( +
    + {filteredLicenses.map(({ name, license }) => ( +
  • + {name === "Xray-core" ? ( + + ) : ( + + {name} + + )} + + {license} + +
  • + ))} +
+ ) : ( +

+ {t("about.noMatchingLicenses")} +

+ )} +
+ + )} +
); diff --git a/src/components/account-page.tsx b/src/components/account-page.tsx index 1ec48b0..17dca0f 100644 --- a/src/components/account-page.tsx +++ b/src/components/account-page.tsx @@ -222,7 +222,7 @@ export function AccountPage({
-
+
@@ -305,7 +305,7 @@ export function AccountPage({ user && getEntitlements(user).browserAutomation && user.isPrimaryDevice === false && ( -

+

{t("account.automationPrimaryOnly")}

)} @@ -314,7 +314,7 @@ export function AccountPage({ getEntitlements(user).browserAutomation && user.isPrimaryDevice === true && (user.deviceCount ?? 1) > 1 && ( -

+

{t("account.automationActiveHere")}

)} diff --git a/src/components/bandwidth-mini-chart.tsx b/src/components/bandwidth-mini-chart.tsx index c6204d9..2336b2f 100644 --- a/src/components/bandwidth-mini-chart.tsx +++ b/src/components/bandwidth-mini-chart.tsx @@ -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, )} > diff --git a/src/components/consistency-warning-dialog.tsx b/src/components/consistency-warning-dialog.tsx index 7e200c5..547eb2b 100644 --- a/src/components/consistency-warning-dialog.tsx +++ b/src/components/consistency-warning-dialog.tsx @@ -98,7 +98,7 @@ export function ConsistencyWarningDialog({ - + {t("consistencyWarning.title")} diff --git a/src/components/cookie-copy-dialog.tsx b/src/components/cookie-copy-dialog.tsx index 8879be6..d18f50f 100644 --- a/src/components/cookie-copy-dialog.tsx +++ b/src/components/cookie-copy-dialog.tsx @@ -407,7 +407,7 @@ export function CookieCopyDialog({ > {p.name} {runningProfiles.has(p.id) && ( - + {t("cookies.copy.running")} )} @@ -451,7 +451,7 @@ export function CookieCopyDialog({
) : error ? ( -
+
{error}
) : filteredDomains.length === 0 ? ( @@ -547,7 +547,7 @@ function DomainRow({ return ( -
+
{ diff --git a/src/components/cookie-management-dialog.tsx b/src/components/cookie-management-dialog.tsx index 74d0de3..37efe76 100644 --- a/src/components/cookie-management-dialog.tsx +++ b/src/components/cookie-management-dialog.tsx @@ -487,7 +487,7 @@ export function CookieManagementDialog({ {importResult && (
-
+
{t("cookies.management.importedSuccess", { imported: importResult.cookies_imported, replaced: importResult.cookies_replaced, @@ -634,7 +634,7 @@ function ExportDomainRow({ return ( -
+
{ diff --git a/src/components/create-group-dialog.tsx b/src/components/create-group-dialog.tsx index a255434..79d5dfe 100644 --- a/src/components/create-group-dialog.tsx +++ b/src/components/create-group-dialog.tsx @@ -93,7 +93,7 @@ export function CreateGroupDialog({
{error && ( -
+
{error}
)} diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index 226829e..6134ecf 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -741,7 +741,7 @@ export function CreateProfileDialog({ autoComplete="new-password" /> {passwordError && ( -

+

{passwordError}

)} @@ -764,7 +764,7 @@ export function CreateProfileDialog({ )} {!isLoadingReleaseTypes && releaseTypesError && (
-

+

{releaseTypesError}

-

+

{t("createProfile.platformUnavailable", { browser: "Wayfern", })} @@ -910,7 +910,7 @@ export function CreateProfileDialog({ {!isLoadingReleaseTypes && releaseTypesError && (

-

+

{releaseTypesError}

-

+

{releaseTypesError}

{progress.failed_count > 0 && ( -

+

{t("toasts.progress.filesFailed", { count: progress.failed_count, })} diff --git a/src/components/data-table-action-bar.tsx b/src/components/data-table-action-bar.tsx index c83a7fa..3705072 100644 --- a/src/components/data-table-action-bar.tsx +++ b/src/components/data-table-action-bar.tsx @@ -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({ {trigger}

{tooltip}

@@ -161,7 +161,7 @@ function DataTableActionBarSelection({

{t("dataTableActionBar.clearSelection")}

diff --git a/src/components/delete-group-dialog.tsx b/src/components/delete-group-dialog.tsx index 26c8fd1..8aec07f 100644 --- a/src/components/delete-group-dialog.tsx +++ b/src/components/delete-group-dialog.tsx @@ -165,7 +165,7 @@ export function DeleteGroupDialog({ @@ -184,7 +184,7 @@ export function DeleteGroupDialog({ )} {error && ( -
+
{error}
)} diff --git a/src/components/edit-group-dialog.tsx b/src/components/edit-group-dialog.tsx index 53f4de5..b26e0f5 100644 --- a/src/components/edit-group-dialog.tsx +++ b/src/components/edit-group-dialog.tsx @@ -103,7 +103,7 @@ export function EditGroupDialog({
{error && ( -
+
{error}
)} diff --git a/src/components/extension-group-assignment-dialog.tsx b/src/components/extension-group-assignment-dialog.tsx index 605967c..92155e0 100644 --- a/src/components/extension-group-assignment-dialog.tsx +++ b/src/components/extension-group-assignment-dialog.tsx @@ -160,7 +160,7 @@ export function ExtensionGroupAssignmentDialog({
{error && ( -
+
{error}
)} diff --git a/src/components/extension-management-dialog.tsx b/src/components/extension-management-dialog.tsx index 133432f..1418bbb 100644 --- a/src/components/extension-management-dialog.tsx +++ b/src/components/extension-management-dialog.tsx @@ -1147,13 +1147,13 @@ export function ExtensionManagementDialog({ disabled={limitedMode} > {t("extensions.extensionsTab")} - + {extensions.length} {t("extensions.groupsTab")} - + {extensionGroups.length} @@ -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" > {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); }} diff --git a/src/components/group-assignment-dialog.tsx b/src/components/group-assignment-dialog.tsx index c16905c..98e0976 100644 --- a/src/components/group-assignment-dialog.tsx +++ b/src/components/group-assignment-dialog.tsx @@ -198,7 +198,7 @@ export function GroupAssignmentDialog({
{error && ( -
+
{error}
)} diff --git a/src/components/group-management-dialog.tsx b/src/components/group-management-dialog.tsx index e25b571..d1f9660 100644 --- a/src/components/group-management-dialog.tsx +++ b/src/components/group-management-dialog.tsx @@ -569,9 +569,15 @@ export function GroupManagementDialog({
-
+
{t("groups.pageTitle")} - + {groups.length}
@@ -591,7 +597,7 @@ export function GroupManagementDialog({
{error && ( -
+
{error}
)} @@ -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" > diff --git a/src/components/home-header.tsx b/src/components/home-header.tsx index 706b8f2..d657401 100644 --- a/src/components/home-header.tsx +++ b/src/components/home-header.tsx @@ -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" > @@ -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" > @@ -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")} > diff --git a/src/components/import-profile-dialog.tsx b/src/components/import-profile-dialog.tsx index 6923ec4..b232435 100644 --- a/src/components/import-profile-dialog.tsx +++ b/src/components/import-profile-dialog.tsx @@ -847,10 +847,11 @@ export function ImportProfileDialog({ {item.status === "imported" && @@ -864,7 +865,7 @@ export function ImportProfileDialog({ {item.name || item.source_path} {item.error && ( - + {translateBackendError(t, new Error(item.error))} )} diff --git a/src/components/integrations-dialog.tsx b/src/components/integrations-dialog.tsx index b17a900..38b8dc8 100644 --- a/src/components/integrations-dialog.tsx +++ b/src/components/integrations-dialog.tsx @@ -535,7 +535,7 @@ export function IntegrationsDialog({

{t("integrations.mcpEnableDescription")} {!termsAccepted && ( - + {t("integrations.mcpAcceptTermsFirst")} )} @@ -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) diff --git a/src/components/permission-dialog.tsx b/src/components/permission-dialog.tsx index 0e2c1de..8e1dfc8 100644 --- a/src/components/permission-dialog.tsx +++ b/src/components/permission-dialog.tsx @@ -205,7 +205,7 @@ export function PermissionDialog({

{!isCurrentPermissionGranted && (
-

+

{permissionType === "microphone" ? t("permissionDialog.notGrantedMicrophone") : t("permissionDialog.notGrantedCamera")} diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 0017915..0132982 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -370,7 +370,7 @@ function ExtCell({ @@ -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", )} > {it.icon} @@ -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" > @@ -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 && ( -

+

{t("profileInfo.launchHook.invalidUrlHint")}

)} - {error &&

{error}

} + {error &&

{error}

}
)} - {error &&

{error}

} + {error &&

{error}

}
); } @@ -1500,7 +1502,7 @@ function NetworkSectionInline({
- {error &&

{error}

} + {error &&

{error}

}
); } @@ -1604,7 +1606,7 @@ function ExtensionsSectionInline({
- {error &&

{error}

} + {error &&

{error}

}
); } @@ -1812,7 +1814,7 @@ function CookiesSectionInline({
)} - {error &&

{error}

} + {error &&

{error}

} )}
@@ -1928,8 +1930,10 @@ function FingerprintSectionInline({ profileBrowser={profile.browser} /> - {error &&

{error}

} - {success && !error &&

{success}

} + {error &&

{error}

} + {success && !error && ( +

{success}

+ )}
- {error &&

{error}

} - {success && !error &&

{success}

} + {error &&

{error}

} + {success && !error && ( +

{success}

+ )} {isRunning && (

@@ -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")} @@ -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" > diff --git a/src/components/profile-password-dialog.tsx b/src/components/profile-password-dialog.tsx index 57bdfc9..44b127a 100644 --- a/src/components/profile-password-dialog.tsx +++ b/src/components/profile-password-dialog.tsx @@ -203,7 +203,7 @@ export function ProfilePasswordDialog({

{(mode === "set" || mode === "change") && (
-

+

{t("profilePassword.warnings.forgetWarningTitle")}

@@ -212,7 +212,7 @@ export function ProfilePasswordDialog({

)} {lockoutSecondsRemaining != null && ( -
+
{t("backendErrors.lockedOut", { duration: formatLockoutDuration(t, lockoutSecondsRemaining), })} diff --git a/src/components/profile-sync-dialog.tsx b/src/components/profile-sync-dialog.tsx index 6b7b05a..1e2b37d 100644 --- a/src/components/profile-sync-dialog.tsx +++ b/src/components/profile-sync-dialog.tsx @@ -267,7 +267,7 @@ export function ProfileSyncDialog({ {syncMode === "Encrypted" && !hasE2ePassword && userChangedMode && ( -
+
{t("sync.mode.noPasswordWarning")}
)} diff --git a/src/components/proxy-assignment-dialog.tsx b/src/components/proxy-assignment-dialog.tsx index cec63c0..25b3b15 100644 --- a/src/components/proxy-assignment-dialog.tsx +++ b/src/components/proxy-assignment-dialog.tsx @@ -299,7 +299,7 @@ export function ProxyAssignmentDialog({
{error && ( -
+
{error}
)} diff --git a/src/components/proxy-check-button.tsx b/src/components/proxy-check-button.tsx index 421d612..6f5e976 100644 --- a/src/components/proxy-check-button.tsx +++ b/src/components/proxy-check-button.tsx @@ -165,7 +165,7 @@ export function ProxyCheckButton({ ) : result && !result.is_valid ? ( - + ) : ( )} @@ -185,10 +185,10 @@ export function ProxyCheckButton({ {[result.city, result.country].filter(Boolean).join(", ") || t("proxyCheck.unknownLocation")}

-

+

{t("proxyCheck.tooltipIp", { ip: result.ip })}

-

+

{t("proxyCheck.tooltipChecked", { time: formatRelativeTime(result.timestamp), })} @@ -197,7 +197,7 @@ export function ProxyCheckButton({ ) : result && !result.is_valid ? (

{t("proxyCheck.tooltipFailedTitle")}

-

+

{t("proxyCheck.tooltipFailed", { time: formatRelativeTime(result.timestamp), })} diff --git a/src/components/proxy-form-dialog.tsx b/src/components/proxy-form-dialog.tsx index 2310b54..1e4e4d7 100644 --- a/src/components/proxy-form-dialog.tsx +++ b/src/components/proxy-form-dialog.tsx @@ -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 (

@@ -176,7 +229,7 @@ export function ProxyFormDialog({
- +
-
+ {isVless ? (
- - { - setForm({ ...form, host: e.target.value }); - }} - placeholder={t("proxies.form.hostPlaceholder")} - disabled={isSubmitting} - /> -
- -
- - { - setForm({ - ...form, - port: Number.parseInt(e.target.value, 10) || 0, - }); - }} - placeholder={t("proxies.form.portPlaceholder")} - min="1" - max="65535" - disabled={isSubmitting} - /> -
-
- -
-
-
+ ) : ( + <> +
+
+ + { + setForm({ ...form, host: e.target.value }); + }} + placeholder={t("proxies.form.hostPlaceholder")} + disabled={isSubmitting} + /> +
-
- - { - setForm({ ...form, password: e.target.value }); - }} - placeholder={t("proxies.form.passwordPlaceholder")} - disabled={isSubmitting} - /> -
-
+
+ + { + setForm({ + ...form, + port: Number.parseInt(e.target.value, 10) || 0, + }); + }} + placeholder={t("proxies.form.portPlaceholder")} + min="1" + max="65535" + disabled={isSubmitting} + /> +
+
+ +
+
+ + { + setForm({ ...form, username: e.target.value }); + }} + placeholder={ + form.proxy_type === "ss" + ? t("proxies.form.cipherPlaceholder") + : t("proxies.form.usernamePlaceholder") + } + disabled={isSubmitting} + /> +
+ +
+ + { + setForm({ ...form, password: e.target.value }); + }} + placeholder={t("proxies.form.passwordPlaceholder")} + disabled={isSubmitting} + /> +
+
+ + )}
diff --git a/src/components/proxy-import-dialog.tsx b/src/components/proxy-import-dialog.tsx index 0c96f99..bed300b 100644 --- a/src/components/proxy-import-dialog.tsx +++ b/src/components/proxy-import-dialog.tsx @@ -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" > - + {proxy.proxy_type}:// {proxy.username && ( @@ -487,7 +487,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { {t("proxies.importDialog.imported")} - + {importResult.imported_count}
@@ -496,7 +496,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { {t("proxies.importDialog.skippedDuplicates")} - + {importResult.skipped_count}
@@ -506,7 +506,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { {t("proxies.importDialog.errors")} - + {importResult.errors.length}
@@ -521,7 +521,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { {importResult.errors.map((error, i) => (
{error}
diff --git a/src/components/proxy-management-dialog.tsx b/src/components/proxy-management-dialog.tsx index 38317da..41166b5 100644 --- a/src/components/proxy-management-dialog.tsx +++ b/src/components/proxy-management-dialog.tsx @@ -1111,13 +1111,13 @@ export function ProxyManagementDialog({ {t("proxies.management.tabProxies")} - + {storedProxies.length} {t("proxies.management.tabVpns")} - + {vpnConfigs.length} @@ -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" > @@ -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" > diff --git a/src/components/rail-nav.tsx b/src/components/rail-nav.tsx index 0ba57f2..8ff42ab 100644 --- a/src/components/rail-nav.tsx +++ b/src/components/rail-nav.tsx @@ -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 && } @@ -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", )} > @@ -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" && } @@ -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" > @@ -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" > diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 054dc18..7851f06 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -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) => { 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 && ( -

{e2eError}

+

+ {e2eError} +

)} -

+

{t("settings.commercial.subscriptionActive", { plan: cloudUser.plan, })} @@ -1272,7 +1261,7 @@ export function SettingsDialog({

) : (
-

+

{t("settings.commercial.trialExpired")}

diff --git a/src/components/sync-all-dialog.tsx b/src/components/sync-all-dialog.tsx index 290a5da..5295019 100644 --- a/src/components/sync-all-dialog.tsx +++ b/src/components/sync-all-dialog.tsx @@ -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" > -

+
diff --git a/src/components/sync-config-dialog.tsx b/src/components/sync-config-dialog.tsx index 0255316..92e8ba1 100644 --- a/src/components/sync-config-dialog.tsx +++ b/src/components/sync-config-dialog.tsx @@ -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") diff --git a/src/components/sync-follower-dialog.tsx b/src/components/sync-follower-dialog.tsx index a23d16c..dc34b60 100644 --- a/src/components/sync-follower-dialog.tsx +++ b/src/components/sync-follower-dialog.tsx @@ -155,7 +155,7 @@ export function SyncFollowerDialog({ return (
{ handleToggle( profile.id, @@ -182,7 +182,7 @@ export function SyncFollowerDialog({ {t("profiles.synchronizer.flakyBadge")} diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx index 1dd4161..09be7f9 100644 --- a/src/components/theme-provider.tsx +++ b/src/components/theme-provider.tsx @@ -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" || diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index 0c300ae..de58e34 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -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: { diff --git a/src/components/ui/animated-tabs.tsx b/src/components/ui/animated-tabs.tsx index c40a2f5..0d2f75f 100644 --- a/src/components/ui/animated-tabs.tsx +++ b/src/components/ui/animated-tabs.tsx @@ -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 && ( diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 39fe660..71f4476 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -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", }, diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 0559cd3..f273772 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -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", diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index f02e3a3..cf27235 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -153,7 +153,7 @@ function CommandItem({ {children} {!hideClose && dismissible && ( - + {t("common.buttons.close")} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 828591e..a92d30e 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -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} diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx index 7e67ac7..738e551 100644 --- a/src/components/ui/radio-group.tsx +++ b/src/components/ui/radio-group.tsx @@ -28,7 +28,7 @@ const RadioGroupItem = React.forwardRef< svg]:px-3", diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index fa29593..68a3713 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -107,7 +107,7 @@ function SelectItem({ ) : result?.is_valid ? ( - + ) : result && !result.is_valid ? ( - + ) : ( )} @@ -125,7 +125,7 @@ export function VpnCheckButton({ ) : result?.is_valid ? (

{t("vpnCheck.tooltipValid")}

-

+

{t("vpnCheck.tooltipChecked", { time: formatRelativeTime(result.timestamp), })} @@ -134,7 +134,7 @@ export function VpnCheckButton({ ) : result && !result.is_valid ? (

{t("vpnCheck.tooltipInvalid")}

-

+

{t("vpnCheck.tooltipChecked", { time: formatRelativeTime(result.timestamp), })} diff --git a/src/components/vpn-import-dialog.tsx b/src/components/vpn-import-dialog.tsx index 7e82126..c6d90ed 100644 --- a/src/components/vpn-import-dialog.tsx +++ b/src/components/vpn-import-dialog.tsx @@ -282,7 +282,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) { {step === "vpn-preview" && vpnPreview && (

- +
{t("vpns.import.configurationLabel", { @@ -332,9 +332,9 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) { > {vpnImportResult.success ? (
- +
-
+
{t("vpns.import.importedSuccess")}
@@ -344,10 +344,10 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
) : (
-
+
{t("vpns.import.importFailed")}
-
+
{vpnImportResult.error}
diff --git a/src/components/wayfern-terms-dialog.tsx b/src/components/wayfern-terms-dialog.tsx index e2a6d0f..26c2f3b 100644 --- a/src/components/wayfern-terms-dialog.tsx +++ b/src/components/wayfern-terms-dialog.tsx @@ -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 diff --git a/src/components/welcome-dialog.tsx b/src/components/welcome-dialog.tsx index 9dec303..fdfd52b 100644 --- a/src/components/welcome-dialog.tsx +++ b/src/components/welcome-dialog.tsx @@ -316,7 +316,7 @@ export function WelcomeDialog({
- +
{t("welcome.license.personalTitle")} @@ -331,7 +331,7 @@ export function WelcomeDialog({
{t("welcome.license.commercialTitle")} - + {t("welcome.license.trialBadge")}
@@ -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" > @@ -445,7 +445,7 @@ export function WelcomeDialog({ {setup.phase === "error" ? ( <>
-

+

{t("welcome.ready.errorTitle")}

diff --git a/src/components/window-drag-area.tsx b/src/components/window-drag-area.tsx index 360d32f..a0e1234 100644 --- a/src/components/window-drag-area.tsx +++ b/src/components/window-drag-area.tsx @@ -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")} > { + assert.equal(THEMES.length, 20); + assert.equal(new Set(THEMES.map((theme) => theme.id)).size, THEMES.length); + assert.deepEqual( + THEMES.filter((theme) => theme.mode === "light") + .map((theme) => theme.id) + .sort(), + [...LIGHT_THEMES].sort(), + ); + for (const theme of THEMES) { + assert.equal(theme.mode, LIGHT_THEMES.has(theme.id) ? "light" : "dark"); + assert.equal(getThemeMode(theme.colors), theme.mode); + } +}); + +test("every semantic fill and foreground pair meets WCAG AA", () => { + const failures = []; + for (const theme of THEMES) { + for (const [surface, content] of SEMANTIC_PAIRS) { + const ratio = contrast( + theme.colors[`--${content}`], + theme.colors[`--${surface}`], + ); + if (ratio < AA_TEXT) { + failures.push( + `${theme.id}: ${surface}/${content} ${ratio.toFixed(2)}:1`, + ); + } + } + } + assertNoContrastFailures(failures); +}); + +test("foreground and muted text remain readable on every content surface", () => { + const failures = []; + for (const theme of THEMES) { + for (const content of ["foreground", "muted-foreground"]) { + for (const surface of CONTENT_SURFACES) { + const ratio = contrast( + theme.colors[`--${content}`], + theme.colors[`--${surface}`], + ); + if (ratio < AA_TEXT) { + failures.push( + `${theme.id}: ${content}/${surface} ${ratio.toFixed(2)}:1`, + ); + } + } + } + } + assertNoContrastFailures(failures); +}); + +test("derived contextual text and control boundaries remain accessible", () => { + const failures = []; + for (const theme of THEMES) { + const derived = getDerivedThemeColors(theme.colors); + for (const role of CONTEXTUAL_ROLES) { + for (const surface of ["background", "card", "popover", "muted"]) { + const surfaceColor = theme.colors[`--${surface}`]; + const contexts = [[surface, surfaceColor]]; + if (surface !== "muted") { + contexts.push( + ...[0.1, 0.2].map((opacity) => [ + `${surface} with ${role} ${opacity * 100}% tint`, + tintedSurface(surfaceColor, theme.colors[`--${role}`], opacity), + ]), + ); + } + for (const [context, background] of contexts) { + const ratio = contrast(derived[`--${role}-text`], background); + if (ratio < AA_TEXT) { + failures.push( + `${theme.id}: ${role}-text/${context} ${ratio.toFixed(2)}:1`, + ); + } + } + } + } + for (const boundary of ["--input", "--ring"]) { + for (const surface of ["background", "card", "popover", "muted"]) { + const ratio = contrast(derived[boundary], theme.colors[`--${surface}`]); + if (ratio < UI_BOUNDARY) { + failures.push( + `${theme.id}: ${boundary}/${surface} ${ratio.toFixed(2)}:1`, + ); + } + } + } + } + assertNoContrastFailures(failures); +}); + +test("unmatched custom palettes derive their mode from the background", () => { + const darkCustom = { ...THEMES[0].colors, "--background": "#20242b" }; + const lightCustom = { ...THEMES[0].colors, "--background": "#f4f1e8" }; + assert.equal(getThemeMode(darkCustom), "dark"); + assert.equal(getThemeMode(lightCustom), "light"); +}); + +test("saved copies of the previous presets migrate without claiming edits", () => { + const houston = THEMES.find((theme) => theme.id === "houston"); + const legacyHouston = { + ...houston.colors, + "--secondary-foreground": "#f7f7f8", + "--accent-foreground": "#f7f7f8", + "--destructive-foreground": "#f7f7f8", + }; + assert.equal(getThemeByColors(legacyHouston)?.id, "houston"); + assert.deepEqual(normalizeThemeColors(legacyHouston), houston.colors); + + const editedHouston = { ...legacyHouston, "--accent": "#123456" }; + assert.equal(getThemeByColors(editedHouston), undefined); + assert.equal(normalizeThemeColors(editedHouston), editedHouston); +}); diff --git a/src/lib/themes.ts b/src/lib/themes.ts index 1a5f5dc..9f040d7 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -30,6 +30,7 @@ export interface ThemeColors extends Record { export interface Theme { id: string; name: string; + mode: "light" | "dark"; colors: ThemeColors; } @@ -37,6 +38,7 @@ export const THEMES: Theme[] = [ { id: "donut-mono", name: "Donut Mono", + mode: "dark", colors: { "--background": "#070707", "--foreground": "#ffffff", @@ -69,6 +71,7 @@ export const THEMES: Theme[] = [ { id: "tokyo-night", name: "Tokyo Night", + mode: "dark", colors: { "--background": "#1a1b26", "--foreground": "#c0caf5", @@ -101,23 +104,24 @@ export const THEMES: Theme[] = [ { id: "dracula", name: "Dracula", + mode: "dark", colors: { "--background": "#282a36", - "--foreground": "#f8f8f2", + "--foreground": "#fcfcfa", "--card": "#44475a", - "--card-foreground": "#f8f8f2", + "--card-foreground": "#fcfcfa", "--popover": "#44475a", - "--popover-foreground": "#f8f8f2", + "--popover-foreground": "#fcfcfa", "--primary": "#bd93f9", "--primary-foreground": "#282a36", "--secondary": "#8be9fd", "--secondary-foreground": "#282a36", "--muted": "#6272a4", - "--muted-foreground": "#f8f8f2", + "--muted-foreground": "#fcfcfa", "--accent": "#ff79c6", "--accent-foreground": "#282a36", "--destructive": "#ff5555", - "--destructive-foreground": "#f8f8f2", + "--destructive-foreground": "#282a36", "--success": "#50fa7b", "--success-foreground": "#282a36", "--warning": "#ffb86c", @@ -133,6 +137,7 @@ export const THEMES: Theme[] = [ { id: "matchalk", name: "Matchalk", + mode: "dark", colors: { "--background": "#273136", "--foreground": "#D1DED3", @@ -145,7 +150,7 @@ export const THEMES: Theme[] = [ "--secondary": "#d2b48c", "--secondary-foreground": "#273136", "--muted": "#323e45", - "--muted-foreground": "#7ea4b0", + "--muted-foreground": "#8badb8", "--accent": "#d2b48c", "--accent-foreground": "#273136", "--destructive": "#ff819f", @@ -165,6 +170,7 @@ export const THEMES: Theme[] = [ { id: "houston", name: "Houston", + mode: "dark", colors: { "--background": "#17191e", "--foreground": "#f7f7f8", @@ -175,13 +181,13 @@ export const THEMES: Theme[] = [ "--primary": "#5755d9", "--primary-foreground": "#f7f7f8", "--secondary": "#f25f4c", - "--secondary-foreground": "#f7f7f8", + "--secondary-foreground": "#17191e", "--muted": "#2a2e39", "--muted-foreground": "#9ca3af", "--accent": "#0ea5e9", - "--accent-foreground": "#f7f7f8", + "--accent-foreground": "#17191e", "--destructive": "#ef4444", - "--destructive-foreground": "#f7f7f8", + "--destructive-foreground": "#17191e", "--success": "#22c55e", "--success-foreground": "#17191e", "--warning": "#f59e0b", @@ -197,6 +203,7 @@ export const THEMES: Theme[] = [ { id: "ayu-dark", name: "Ayu Dark", + mode: "dark", colors: { "--background": "#0a0e14", "--foreground": "#b3b1ad", @@ -209,11 +216,11 @@ export const THEMES: Theme[] = [ "--secondary": "#ffb454", "--secondary-foreground": "#0a0e14", "--muted": "#1f2430", - "--muted-foreground": "#5c6773", + "--muted-foreground": "#858d96", "--accent": "#d2a6ff", "--accent-foreground": "#0a0e14", "--destructive": "#f07178", - "--destructive-foreground": "#b3b1ad", + "--destructive-foreground": "#0a0e14", "--success": "#c2d94c", "--success-foreground": "#0a0e14", "--warning": "#ffb454", @@ -229,6 +236,7 @@ export const THEMES: Theme[] = [ { id: "ayu-light", name: "Ayu Light", + mode: "light", // Source: ayu-theme/ayu-colors light.yaml. Primary uses the iconic // Ayu orange instead of blue — that's the colour the theme is known for. colors: { @@ -239,19 +247,19 @@ export const THEMES: Theme[] = [ "--popover": "#ffffff", "--popover-foreground": "#5c6166", "--primary": "#f29718", - "--primary-foreground": "#ffffff", + "--primary-foreground": "#0a0e14", "--secondary": "#399ee6", - "--secondary-foreground": "#ffffff", + "--secondary-foreground": "#0a0e14", "--muted": "#ebeef0", - "--muted-foreground": "#828e9f", + "--muted-foreground": "#626b78", "--accent": "#a37acc", - "--accent-foreground": "#ffffff", + "--accent-foreground": "#0a0e14", "--destructive": "#e65050", - "--destructive-foreground": "#ffffff", + "--destructive-foreground": "#0a0e14", "--success": "#86b300", - "--success-foreground": "#ffffff", + "--success-foreground": "#0a0e14", "--warning": "#fa8532", - "--warning-foreground": "#ffffff", + "--warning-foreground": "#0a0e14", "--border": "#c8d0d6", "--chart-1": "#f29718", "--chart-2": "#86b300", @@ -263,6 +271,7 @@ export const THEMES: Theme[] = [ { id: "catppuccin-latte", name: "Catppuccin Latte", + mode: "light", // Source: github.com/catppuccin/palette/blob/main/palette.json // Primary uses mauve (purple) — the colour Catppuccin is most known // for — instead of blue, to differentiate from the many blue themes. @@ -270,25 +279,25 @@ export const THEMES: Theme[] = [ // mid-points between Latte and Mocha and added little variety. colors: { "--background": "#eff1f5", - "--foreground": "#4c4f69", + "--foreground": "#484b64", "--card": "#ccd0da", - "--card-foreground": "#4c4f69", + "--card-foreground": "#484b64", "--popover": "#ccd0da", - "--popover-foreground": "#4c4f69", + "--popover-foreground": "#484b64", "--primary": "#8839ef", "--primary-foreground": "#eff1f5", "--secondary": "#1e66f5", - "--secondary-foreground": "#eff1f5", + "--secondary-foreground": "#ffffff", "--muted": "#bcc0cc", - "--muted-foreground": "#6c6f85", + "--muted-foreground": "#4b4d5c", "--accent": "#ea76cb", - "--accent-foreground": "#eff1f5", + "--accent-foreground": "#353637", "--destructive": "#d20f39", "--destructive-foreground": "#eff1f5", "--success": "#40a02b", - "--success-foreground": "#eff1f5", + "--success-foreground": "#242525", "--warning": "#df8e1d", - "--warning-foreground": "#eff1f5", + "--warning-foreground": "#363637", "--border": "#9ca0b0", "--chart-1": "#8839ef", "--chart-2": "#40a02b", @@ -300,6 +309,7 @@ export const THEMES: Theme[] = [ { id: "catppuccin-mocha", name: "Catppuccin Mocha", + mode: "dark", // Source: github.com/catppuccin/palette/blob/main/palette.json // Primary uses mauve (purple) — Catppuccin's signature colour. colors: { @@ -314,7 +324,7 @@ export const THEMES: Theme[] = [ "--secondary": "#89b4fa", "--secondary-foreground": "#1e1e2e", "--muted": "#45475a", - "--muted-foreground": "#a6adc8", + "--muted-foreground": "#bac2de", "--accent": "#f5c2e7", "--accent-foreground": "#1e1e2e", "--destructive": "#f38ba8", @@ -334,6 +344,7 @@ export const THEMES: Theme[] = [ { id: "nord", name: "Nord", + mode: "dark", // Source: nordtheme.com/docs/colors-and-palettes (Polar Night / Snow Storm / Frost / Aurora) colors: { "--background": "#2e3440", @@ -349,9 +360,9 @@ export const THEMES: Theme[] = [ "--muted": "#434c5e", "--muted-foreground": "#d8dee9", "--accent": "#b48ead", - "--accent-foreground": "#2e3440", + "--accent-foreground": "#2b313c", "--destructive": "#bf616a", - "--destructive-foreground": "#eceff4", + "--destructive-foreground": "#111112", "--success": "#a3be8c", "--success-foreground": "#2e3440", "--warning": "#ebcb8b", @@ -367,6 +378,7 @@ export const THEMES: Theme[] = [ { id: "gruvbox-dark", name: "Gruvbox Dark", + mode: "dark", // Source: github.com/morhetz/gruvbox medium-contrast dark palette. // Primary uses the iconic Gruvbox orange instead of blue. colors: { @@ -381,11 +393,11 @@ export const THEMES: Theme[] = [ "--secondary": "#83a598", "--secondary-foreground": "#282828", "--muted": "#504945", - "--muted-foreground": "#a89984", + "--muted-foreground": "#d5c4a1", "--accent": "#d3869b", "--accent-foreground": "#282828", "--destructive": "#fb4934", - "--destructive-foreground": "#282828", + "--destructive-foreground": "#222222", "--success": "#b8bb26", "--success-foreground": "#282828", "--warning": "#fabd2f", @@ -401,6 +413,7 @@ export const THEMES: Theme[] = [ { id: "gruvbox-light", name: "Gruvbox Light", + mode: "light", // Source: github.com/morhetz/gruvbox medium-contrast light palette. // Primary uses the iconic Gruvbox orange instead of blue. colors: { @@ -415,15 +428,15 @@ export const THEMES: Theme[] = [ "--secondary": "#076678", "--secondary-foreground": "#fbf1c7", "--muted": "#d5c4a1", - "--muted-foreground": "#7c6f64", + "--muted-foreground": "#595048", "--accent": "#8f3f71", "--accent-foreground": "#fbf1c7", "--destructive": "#9d0006", "--destructive-foreground": "#fbf1c7", "--success": "#79740e", - "--success-foreground": "#fbf1c7", + "--success-foreground": "#fdf9e6", "--warning": "#b57614", - "--warning-foreground": "#fbf1c7", + "--warning-foreground": "#1b1a16", "--border": "#a89984", "--chart-1": "#af3a03", "--chart-2": "#79740e", @@ -435,24 +448,25 @@ export const THEMES: Theme[] = [ { id: "solarized-dark", name: "Solarized Dark", + mode: "dark", // Source: ethanschoonover.com/solarized — base03 / base02 / base01 / base00 / base0 / base1 colors: { "--background": "#002b36", - "--foreground": "#839496", + "--foreground": "#8d9d9f", "--card": "#073642", - "--card-foreground": "#839496", + "--card-foreground": "#8d9d9f", "--popover": "#073642", - "--popover-foreground": "#839496", + "--popover-foreground": "#8d9d9f", "--primary": "#268bd2", - "--primary-foreground": "#002b36", + "--primary-foreground": "#002028", "--secondary": "#2aa198", "--secondary-foreground": "#002b36", "--muted": "#073642", "--muted-foreground": "#93a1a1", "--accent": "#6c71c4", - "--accent-foreground": "#fdf6e3", + "--accent-foreground": "#070706", "--destructive": "#dc322f", - "--destructive-foreground": "#fdf6e3", + "--destructive-foreground": "#fffefd", "--success": "#859900", "--success-foreground": "#002b36", "--warning": "#b58900", @@ -468,28 +482,29 @@ export const THEMES: Theme[] = [ { id: "solarized-light", name: "Solarized Light", + mode: "light", // Source: ethanschoonover.com/solarized — same accents, inverted base scale colors: { "--background": "#fdf6e3", - "--foreground": "#657b83", + "--foreground": "#576a71", "--card": "#eee8d5", - "--card-foreground": "#657b83", + "--card-foreground": "#576a71", "--popover": "#eee8d5", - "--popover-foreground": "#657b83", + "--popover-foreground": "#576a71", "--primary": "#268bd2", - "--primary-foreground": "#fdf6e3", + "--primary-foreground": "#1d1d1a", "--secondary": "#2aa198", - "--secondary-foreground": "#fdf6e3", + "--secondary-foreground": "#2a2926", "--muted": "#eee8d5", - "--muted-foreground": "#93a1a1", + "--muted-foreground": "#606969", "--accent": "#6c71c4", - "--accent-foreground": "#fdf6e3", + "--accent-foreground": "#070706", "--destructive": "#dc322f", - "--destructive-foreground": "#fdf6e3", + "--destructive-foreground": "#fffefd", "--success": "#859900", - "--success-foreground": "#fdf6e3", + "--success-foreground": "#292825", "--warning": "#b58900", - "--warning-foreground": "#fdf6e3", + "--warning-foreground": "#292825", "--border": "#cdc7b3", "--chart-1": "#268bd2", "--chart-2": "#859900", @@ -501,6 +516,7 @@ export const THEMES: Theme[] = [ { id: "one-dark", name: "One Dark", + mode: "dark", // Source: github.com/atom/atom one-dark-syntax/styles/colors.less (mono-1, hue-1..6) colors: { "--background": "#282c34", @@ -514,11 +530,11 @@ export const THEMES: Theme[] = [ "--secondary": "#56b6c2", "--secondary-foreground": "#282c34", "--muted": "#3e4451", - "--muted-foreground": "#7d8590", + "--muted-foreground": "#abb2bf", "--accent": "#c678dd", "--accent-foreground": "#282c34", "--destructive": "#e06c75", - "--destructive-foreground": "#282c34", + "--destructive-foreground": "#252830", "--success": "#98c379", "--success-foreground": "#282c34", "--warning": "#e5c07b", @@ -534,6 +550,7 @@ export const THEMES: Theme[] = [ { id: "monokai-pro", name: "Monokai Pro", + mode: "dark", // Source: classic Monokai filter (monokai-pro.nvim palette/classic.lua). // Primary uses Monokai's signature green instead of cyan. colors: { @@ -548,11 +565,11 @@ export const THEMES: Theme[] = [ "--secondary": "#66d9ef", "--secondary-foreground": "#272822", "--muted": "#3b3c35", - "--muted-foreground": "#919288", + "--muted-foreground": "#a6a79f", "--accent": "#ae81ff", "--accent-foreground": "#272822", "--destructive": "#f92672", - "--destructive-foreground": "#fdfff1", + "--destructive-foreground": "#1a1a19", "--success": "#a6e22e", "--success-foreground": "#272822", "--warning": "#e6db74", @@ -568,6 +585,7 @@ export const THEMES: Theme[] = [ { id: "rose-pine", name: "Rosé Pine", + mode: "dark", // Source: github.com/rose-pine/palette/blob/main/palette.json. // Primary uses iris (purple) — the iconic Rosé Pine accent — and // success uses pine. Destructive stays love (pink), which is correct @@ -590,7 +608,7 @@ export const THEMES: Theme[] = [ "--destructive": "#eb6f92", "--destructive-foreground": "#191724", "--success": "#31748f", - "--success-foreground": "#e0def4", + "--success-foreground": "#f1f0fa", "--warning": "#f6c177", "--warning-foreground": "#191724", "--border": "#403d52", @@ -604,6 +622,7 @@ export const THEMES: Theme[] = [ { id: "rose-pine-dawn", name: "Rosé Pine Dawn", + mode: "light", // Source: github.com/rose-pine/palette/blob/main/palette.json (dawn variant). // Primary uses iris (purple) for parity with the dark variant. colors: { @@ -614,19 +633,19 @@ export const THEMES: Theme[] = [ "--popover": "#fffaf3", "--popover-foreground": "#575279", "--primary": "#907aa9", - "--primary-foreground": "#faf4ed", + "--primary-foreground": "#1a1a19", "--secondary": "#56949f", - "--secondary-foreground": "#faf4ed", + "--secondary-foreground": "#242322", "--muted": "#f2e9e1", - "--muted-foreground": "#797593", + "--muted-foreground": "#696680", "--accent": "#d7827e", - "--accent-foreground": "#faf4ed", + "--accent-foreground": "#32302f", "--destructive": "#b4637a", - "--destructive-foreground": "#faf4ed", + "--destructive-foreground": "#0e0e0e", "--success": "#286983", "--success-foreground": "#faf4ed", "--warning": "#ea9d34", - "--warning-foreground": "#faf4ed", + "--warning-foreground": "#42403e", "--border": "#cecacd", "--chart-1": "#907aa9", "--chart-2": "#56949f", @@ -638,6 +657,7 @@ export const THEMES: Theme[] = [ { id: "github-dark", name: "GitHub Dark", + mode: "dark", // Source: github.com/primer/primitives base color tokens (dark default) colors: { "--background": "#0d1117", @@ -647,17 +667,17 @@ export const THEMES: Theme[] = [ "--popover": "#151b23", "--popover-foreground": "#f0f6fc", "--primary": "#1f6feb", - "--primary-foreground": "#f0f6fc", + "--primary-foreground": "#fefeff", "--secondary": "#58a6ff", "--secondary-foreground": "#0d1117", "--muted": "#212830", "--muted-foreground": "#9198a1", "--accent": "#8957e5", - "--accent-foreground": "#f0f6fc", + "--accent-foreground": "#ffffff", "--destructive": "#da3633", - "--destructive-foreground": "#f0f6fc", + "--destructive-foreground": "#ffffff", "--success": "#238636", - "--success-foreground": "#f0f6fc", + "--success-foreground": "#fefeff", "--warning": "#d29922", "--warning-foreground": "#0d1117", "--border": "#3d444d", @@ -671,6 +691,7 @@ export const THEMES: Theme[] = [ { id: "github-light", name: "GitHub Light", + mode: "light", // Source: github.com/primer/primitives base color tokens (light default) colors: { "--background": "#ffffff", @@ -682,7 +703,7 @@ export const THEMES: Theme[] = [ "--primary": "#0969da", "--primary-foreground": "#ffffff", "--secondary": "#54aeff", - "--secondary-foreground": "#ffffff", + "--secondary-foreground": "#25292e", "--muted": "#eff2f5", "--muted-foreground": "#59636e", "--accent": "#8250df", @@ -692,7 +713,7 @@ export const THEMES: Theme[] = [ "--success": "#1a7f37", "--success-foreground": "#ffffff", "--warning": "#bf8700", - "--warning-foreground": "#ffffff", + "--warning-foreground": "#25292e", "--border": "#d1d9e0", "--chart-1": "#0969da", "--chart-2": "#1a7f37", @@ -703,6 +724,112 @@ export const THEMES: Theme[] = [ }, ]; +const LEGACY_PRESET_OVERRIDES: Record> = { + dracula: { + "--foreground": "#f8f8f2", + "--card-foreground": "#f8f8f2", + "--popover-foreground": "#f8f8f2", + "--muted-foreground": "#f8f8f2", + "--destructive-foreground": "#f8f8f2", + }, + matchalk: { + "--muted-foreground": "#7ea4b0", + }, + houston: { + "--secondary-foreground": "#f7f7f8", + "--accent-foreground": "#f7f7f8", + "--destructive-foreground": "#f7f7f8", + }, + "ayu-dark": { + "--muted-foreground": "#5c6773", + "--destructive-foreground": "#b3b1ad", + }, + "ayu-light": { + "--primary-foreground": "#ffffff", + "--secondary-foreground": "#ffffff", + "--muted-foreground": "#828e9f", + "--accent-foreground": "#ffffff", + "--destructive-foreground": "#ffffff", + "--success-foreground": "#ffffff", + "--warning-foreground": "#ffffff", + }, + "catppuccin-latte": { + "--foreground": "#4c4f69", + "--card-foreground": "#4c4f69", + "--popover-foreground": "#4c4f69", + "--secondary-foreground": "#eff1f5", + "--muted-foreground": "#6c6f85", + "--accent-foreground": "#eff1f5", + "--success-foreground": "#eff1f5", + "--warning-foreground": "#eff1f5", + }, + "catppuccin-mocha": { + "--muted-foreground": "#a6adc8", + }, + nord: { + "--accent-foreground": "#2e3440", + "--destructive-foreground": "#eceff4", + }, + "gruvbox-dark": { + "--muted-foreground": "#a89984", + "--destructive-foreground": "#282828", + }, + "gruvbox-light": { + "--muted-foreground": "#7c6f64", + "--success-foreground": "#fbf1c7", + "--warning-foreground": "#fbf1c7", + }, + "solarized-dark": { + "--foreground": "#839496", + "--card-foreground": "#839496", + "--popover-foreground": "#839496", + "--primary-foreground": "#002b36", + "--accent-foreground": "#fdf6e3", + "--destructive-foreground": "#fdf6e3", + }, + "solarized-light": { + "--foreground": "#657b83", + "--card-foreground": "#657b83", + "--popover-foreground": "#657b83", + "--primary-foreground": "#fdf6e3", + "--secondary-foreground": "#fdf6e3", + "--muted-foreground": "#93a1a1", + "--accent-foreground": "#fdf6e3", + "--destructive-foreground": "#fdf6e3", + "--success-foreground": "#fdf6e3", + "--warning-foreground": "#fdf6e3", + }, + "one-dark": { + "--muted-foreground": "#7d8590", + "--destructive-foreground": "#282c34", + }, + "monokai-pro": { + "--muted-foreground": "#919288", + "--destructive-foreground": "#fdfff1", + }, + "rose-pine": { + "--success-foreground": "#e0def4", + }, + "rose-pine-dawn": { + "--primary-foreground": "#faf4ed", + "--secondary-foreground": "#faf4ed", + "--muted-foreground": "#797593", + "--accent-foreground": "#faf4ed", + "--destructive-foreground": "#faf4ed", + "--warning-foreground": "#faf4ed", + }, + "github-dark": { + "--primary-foreground": "#f0f6fc", + "--accent-foreground": "#f0f6fc", + "--destructive-foreground": "#f0f6fc", + "--success-foreground": "#f0f6fc", + }, + "github-light": { + "--secondary-foreground": "#ffffff", + "--warning-foreground": "#ffffff", + }, +}; + export const THEME_VARIABLES: Array<{ key: keyof ThemeColors; label: string }> = [ { key: "--background", label: "Background" }, @@ -740,18 +867,303 @@ export function getThemeById(id: string): Theme | undefined { export function getThemeByColors( colors: Record, ): Theme | undefined { + const matches = (expected: Record) => + THEME_VARIABLES.every(({ key }) => expected[key] === colors[key]); + const current = THEMES.find((theme) => matches(theme.colors)); + if (current) return current; + return THEMES.find((theme) => { - return THEME_VARIABLES.every(({ key }) => { - return theme.colors[key] === colors[key]; - }); + const overrides = LEGACY_PRESET_OVERRIDES[theme.id]; + if (!overrides) return false; + const legacyColors: Record = { ...theme.colors }; + for (const [key, value] of Object.entries(overrides)) { + if (value !== undefined) legacyColors[key] = value; + } + return matches(legacyColors); }); } +export function normalizeThemeColors( + colors: Record, +): Record { + return getThemeByColors(colors)?.colors ?? colors; +} + +interface RgbaColor { + r: number; + g: number; + b: number; + a: number; +} + +export const DERIVED_THEME_VARIABLES = [ + "--primary-text", + "--destructive-text", + "--success-text", + "--warning-text", + "--input", + "--ring", + "--sidebar", + "--sidebar-foreground", + "--sidebar-primary", + "--sidebar-primary-foreground", + "--sidebar-accent", + "--sidebar-accent-foreground", + "--sidebar-border", + "--sidebar-ring", +] as const; + +function parseCssColor(value: string | undefined): RgbaColor | null { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + const hex = normalized.match(/^#([\da-f]{3,8})$/)?.[1]; + if (hex) { + if (hex.length === 3 || hex.length === 4) { + return { + r: Number.parseInt(`${hex[0]}${hex[0]}`, 16), + g: Number.parseInt(`${hex[1]}${hex[1]}`, 16), + b: Number.parseInt(`${hex[2]}${hex[2]}`, 16), + a: + hex.length === 4 + ? Number.parseInt(`${hex[3]}${hex[3]}`, 16) / 255 + : 1, + }; + } + if (hex.length === 6 || hex.length === 8) { + return { + r: Number.parseInt(hex.slice(0, 2), 16), + g: Number.parseInt(hex.slice(2, 4), 16), + b: Number.parseInt(hex.slice(4, 6), 16), + a: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1, + }; + } + } + + const rgb = normalized.match( + /^rgba?\(\s*([\d.]+)\s*[, ]\s*([\d.]+)\s*[, ]\s*([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/, + ); + if (!rgb) return null; + const alpha = rgb[4]?.endsWith("%") + ? Number.parseFloat(rgb[4]) / 100 + : Number.parseFloat(rgb[4] ?? "1"); + const channels = rgb.slice(1, 4).map(Number); + if ( + channels.some((channel) => !Number.isFinite(channel)) || + !Number.isFinite(alpha) + ) { + return null; + } + return { + r: Math.min(255, Math.max(0, channels[0])), + g: Math.min(255, Math.max(0, channels[1])), + b: Math.min(255, Math.max(0, channels[2])), + a: Math.min(1, Math.max(0, alpha)), + }; +} + +function mixColor(from: RgbaColor, to: RgbaColor, amount: number): RgbaColor { + return { + r: from.r + (to.r - from.r) * amount, + g: from.g + (to.g - from.g) * amount, + b: from.b + (to.b - from.b) * amount, + a: from.a + (to.a - from.a) * amount, + }; +} + +function compositeColor(foreground: RgbaColor, background: RgbaColor) { + const alpha = foreground.a + background.a * (1 - foreground.a); + if (alpha === 0) return { r: 0, g: 0, b: 0, a: 0 }; + return { + r: + (foreground.r * foreground.a + + background.r * background.a * (1 - foreground.a)) / + alpha, + g: + (foreground.g * foreground.a + + background.g * background.a * (1 - foreground.a)) / + alpha, + b: + (foreground.b * foreground.a + + background.b * background.a * (1 - foreground.a)) / + alpha, + a: alpha, + }; +} + +function relativeLuminance(color: RgbaColor) { + const linear = [color.r, color.g, color.b].map((channel) => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; +} + +function contrastRatio(foreground: RgbaColor, background: RgbaColor) { + const opaqueForeground = compositeColor(foreground, background); + const foregroundLuminance = relativeLuminance(opaqueForeground); + const backgroundLuminance = relativeLuminance(background); + const lighter = Math.max(foregroundLuminance, backgroundLuminance); + const darker = Math.min(foregroundLuminance, backgroundLuminance); + return (lighter + 0.05) / (darker + 0.05); +} + +function colorToCss(color: RgbaColor) { + const channels = [color.r, color.g, color.b].map((channel) => + Math.round(channel).toString(16).padStart(2, "0"), + ); + if (color.a >= 0.999) return `#${channels.join("")}`; + return `rgba(${Math.round(color.r)}, ${Math.round(color.g)}, ${Math.round( + color.b, + )}, ${color.a.toFixed(3)})`; +} + +function findReadableColor( + sourceValue: string | undefined, + surfaceValues: Array, + minimumContrast: number, + fallback: string, + includeTintedSurfaces = false, + tintSurfaceValues = surfaceValues, +) { + const source = parseCssColor(sourceValue); + const surfaces = surfaceValues + .map(parseCssColor) + .filter((surface): surface is RgbaColor => surface !== null); + const tintSurfaces = tintSurfaceValues + .map(parseCssColor) + .filter((surface): surface is RgbaColor => surface !== null); + if (!source || surfaces.length === 0) return fallback; + + const testedSurfaces = includeTintedSurfaces + ? [ + ...surfaces, + ...tintSurfaces.flatMap((surface) => + [0.1, 0.2].map((opacity) => + compositeColor({ ...source, a: source.a * opacity }, surface), + ), + ), + ] + : surfaces; + const targetContrast = minimumContrast + 0.05; + const isReadable = (candidate: RgbaColor) => + testedSurfaces.every( + (surface) => contrastRatio(candidate, surface) >= targetContrast, + ); + if (isReadable(source)) return sourceValue ?? fallback; + + const targets: RgbaColor[] = [ + { r: 0, g: 0, b: 0, a: 1 }, + { r: 255, g: 255, b: 255, a: 1 }, + ]; + for (let step = 1; step <= 1000; step += 1) { + const amount = step / 1000; + for (const target of targets) { + const candidate = mixColor(source, target, amount); + if (isReadable(candidate)) return colorToCss(candidate); + } + } + return fallback; +} + +export function getThemeMode(colors: Record): "light" | "dark" { + const preset = getThemeByColors(colors); + if (preset) return preset.mode; + + const background = parseCssColor(colors["--background"]); + if (!background) return "dark"; + const opaqueBackground = compositeColor(background, { + r: 255, + g: 255, + b: 255, + a: 1, + }); + const blackContrast = contrastRatio( + { r: 0, g: 0, b: 0, a: 1 }, + opaqueBackground, + ); + const whiteContrast = contrastRatio( + { r: 255, g: 255, b: 255, a: 1 }, + opaqueBackground, + ); + return blackContrast >= whiteContrast ? "light" : "dark"; +} + +export function getDerivedThemeColors( + colors: Record, +): Record<(typeof DERIVED_THEME_VARIABLES)[number], string> { + const surfaces = [ + colors["--background"], + colors["--card"], + colors["--popover"], + colors["--muted"], + ]; + const tintSurfaces = surfaces.slice(0, 3); + const foreground = colors["--foreground"] ?? "#ffffff"; + const primaryText = findReadableColor( + colors["--primary"], + surfaces, + 4.5, + foreground, + true, + tintSurfaces, + ); + const destructiveText = findReadableColor( + colors["--destructive"], + surfaces, + 4.5, + foreground, + true, + tintSurfaces, + ); + const successText = findReadableColor( + colors["--success"], + surfaces, + 4.5, + foreground, + true, + tintSurfaces, + ); + const warningText = findReadableColor( + colors["--warning"], + surfaces, + 4.5, + foreground, + true, + tintSurfaces, + ); + const input = findReadableColor(colors["--border"], surfaces, 3, foreground); + + return { + "--primary-text": primaryText, + "--destructive-text": destructiveText, + "--success-text": successText, + "--warning-text": warningText, + "--input": input, + "--ring": primaryText, + "--sidebar": colors["--background"], + "--sidebar-foreground": colors["--foreground"], + "--sidebar-primary": colors["--primary"], + "--sidebar-primary-foreground": colors["--primary-foreground"], + "--sidebar-accent": colors["--accent"], + "--sidebar-accent-foreground": colors["--accent-foreground"], + "--sidebar-border": colors["--border"], + "--sidebar-ring": primaryText, + }; +} + export function applyThemeColors(colors: Record): void { + const normalizedColors = normalizeThemeColors(colors); const root = document.documentElement; - Object.entries(colors).forEach(([key, value]) => { + root.classList.remove("light", "dark"); + root.classList.add(getThemeMode(normalizedColors)); + Object.entries(normalizedColors).forEach(([key, value]) => { root.style.setProperty(key, value, "important"); }); + Object.entries(getDerivedThemeColors(normalizedColors)).forEach( + ([key, value]) => { + root.style.setProperty(key, value, "important"); + }, + ); } export function clearThemeColors(): void { @@ -759,6 +1171,9 @@ export function clearThemeColors(): void { THEME_VARIABLES.forEach(({ key }) => { root.style.removeProperty(key as string); }); + DERIVED_THEME_VARIABLES.forEach((key) => { + root.style.removeProperty(key); + }); } /** diff --git a/src/styles/globals.css b/src/styles/globals.css index 1b9cbdc..6604e0e 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -32,6 +32,7 @@ --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); + --color-primary-text: var(--primary-text); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); @@ -40,10 +41,13 @@ --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); + --color-destructive-text: var(--destructive-text); --color-success: var(--success); --color-success-foreground: var(--success-foreground); + --color-success-text: var(--success-text); --color-warning: var(--warning); --color-warning-foreground: var(--warning-foreground); + --color-warning-text: var(--warning-text); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); @@ -73,6 +77,7 @@ --popover-foreground: oklch(0.141 0.005 285.823); --primary: oklch(0.21 0.006 285.885); --primary-foreground: oklch(0.985 0 0); + --primary-text: oklch(0.21 0.006 285.885); --secondary: oklch(0.967 0.001 286.375); --secondary-foreground: oklch(0.21 0.006 285.885); --muted: oklch(0.967 0.001 286.375); @@ -81,10 +86,13 @@ --accent-foreground: oklch(0.21 0.006 285.885); --destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.985 0 0); + --destructive-text: #b91c1c; --success: oklch(0.6 0.2 145); --success-foreground: oklch(0.985 0 0); + --success-text: #15753a; --warning: oklch(0.75 0.15 75); --warning-foreground: oklch(0.141 0.005 285.823); + --warning-text: #854d0e; --border: oklch(0.92 0.004 286.32); --input: oklch(0.92 0.004 286.32); --ring: oklch(0.705 0.015 286.067); @@ -118,6 +126,7 @@ --popover-foreground: #e4e4e4; --primary: #ffffff; --primary-foreground: #070707; + --primary-text: #ffffff; --secondary: #161616; --secondary-foreground: #e4e4e4; --muted: #161616; @@ -126,10 +135,13 @@ --accent-foreground: #ffffff; --destructive: #ec6a5e; --destructive-foreground: #070707; + --destructive-text: #ec6a5e; --success: #61c554; --success-foreground: #070707; + --success-text: #61c554; --warning: #f4be4f; --warning-foreground: #070707; + --warning-text: #f4be4f; --border: rgba(255, 255, 255, 0.06); --input: rgba(255, 255, 255, 0.1); --ring: #6b6b6b; diff --git a/src/types.ts b/src/types.ts index 211755b..824fb33 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,10 @@ export interface ProxySettings { - proxy_type: string; // "http", "https", "socks4", "socks5", or "ss" (Shadowsocks) + proxy_type: string; host: string; port: number; username?: string; password?: string; + vless_uri?: string; } export interface TableSortingSettings { @@ -545,6 +546,7 @@ export interface ParsedProxyLine { port: number; username?: string; password?: string; + vless_uri?: string; original_line: string; }