Compare commits

...

24 Commits

Author SHA1 Message Date
zhom c816fee184 chore: version bump 2026-02-20 09:04:37 +04:00
zhom 4872dcc8ad refactor: sync 2026-02-20 07:22:42 +04:00
zhom 8bc1ea500b chore: linting 2026-02-20 05:00:13 +04:00
zhom 7ed19f3a8f chore: pnpm update 2026-02-20 04:55:40 +04:00
zhom e5663515a7 refactor: cleanup and decouple 2026-02-20 04:44:35 +04:00
zhom 0f579cb97d fix: fetch auto-update on windows 2026-02-18 15:32:10 +04:00
zhom de896f895c refactor: check subscription 2026-02-18 13:23:20 +04:00
zhom 3d57a622b1 chore: version bump 2026-02-18 09:41:20 +04:00
zhom 5dfe7cb216 fix: download zip instead of exe 2026-02-18 09:40:45 +04:00
zhom dea0181009 chore: version bump 2026-02-17 23:15:36 +04:00
zhom 4983f622d0 fix: properly signed the app 2026-02-17 23:15:06 +04:00
zhom 6654ab9fdc refactor: simplify auto-update login 2026-02-17 22:54:45 +04:00
zhom d490ad3612 chore: version bump 2026-02-17 17:31:43 +04:00
zhom e31de5ac99 fix: proper state geotargeting 2026-02-17 17:31:15 +04:00
zhom 7cd3e922f5 chore: remove brew comment 2026-02-17 16:37:07 +04:00
zhom 547bd89de9 fix: find wayfern binary 2026-02-17 16:36:57 +04:00
zhom edabfd0831 chore: download assets earlier 2026-02-17 00:00:58 +04:00
zhom 127912c68c chore: use correct dir for temp repo 2026-02-16 22:18:41 +04:00
zhom af2aa36ac6 feat: block launching profiles for incompatible systems 2026-02-16 22:18:11 +04:00
zhom d52493b7e4 docs: add email links 2026-02-16 21:08:28 +04:00
zhom dfc94c10ff chore: add latest release for nightly 2026-02-16 20:45:38 +04:00
zhom a008e11504 refactor: properly handle admin account 2026-02-16 19:58:23 +04:00
zhom 6f28ed3a47 chore: sign ad-hoc only if no env variables are set 2026-02-16 19:57:41 +04:00
zhom c30a44a13d docs: update preview 2026-02-16 19:11:45 +04:00
80 changed files with 6932 additions and 2032 deletions
+14
View File
@@ -230,3 +230,17 @@ jobs:
# with:
# branch: main
# commit_message: "docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]"
bump-homebrew-cask:
needs: [release]
runs-on: macos-latest
permissions:
contents: read
steps:
- name: Bump Homebrew cask
env:
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
brew tap --force homebrew/cask
brew bump-cask-pr --version "$VERSION" --no-browse donutbrowser
+52
View File
@@ -234,3 +234,55 @@ jobs:
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
rm -f $RUNNER_TEMP/build_certificate.p12 || true
update-nightly-release:
needs: [rolling-release]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Generate nightly tag
id: tag
run: |
TIMESTAMP=$(date -u +"%Y-%m-%d")
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
echo "nightly_tag=nightly-${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
- name: Update rolling nightly release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
NIGHTLY_TAG="${{ steps.tag.outputs.nightly_tag }}"
ASSETS_DIR="/tmp/nightly-assets"
# Download all assets from the per-commit nightly release
mkdir -p "$ASSETS_DIR"
gh release download "$NIGHTLY_TAG" --dir "$ASSETS_DIR" --clobber
# Rename versioned filenames to stable nightly names
cd "$ASSETS_DIR"
for f in Donut_*_aarch64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.dmg; done
for f in Donut_*_x64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_x64.dmg; done
for f in Donut_*_x64-setup.exe; do [ -f "$f" ] && mv "$f" Donut_nightly_x64-setup.exe; done
for f in Donut_*_aarch64.AppImage; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.AppImage; done
for f in Donut_*_amd64.AppImage; do [ -f "$f" ] && mv "$f" Donut_nightly_amd64.AppImage; done
for f in Donut_*_amd64.deb; do [ -f "$f" ] && mv "$f" Donut_nightly_amd64.deb; done
for f in Donut_*_arm64.deb; do [ -f "$f" ] && mv "$f" Donut_nightly_arm64.deb; done
for f in Donut-*.x86_64.rpm; do [ -f "$f" ] && mv "$f" Donut_nightly_x86_64.rpm; done
for f in Donut-*.aarch64.rpm; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.rpm; done
cd "$GITHUB_WORKSPACE"
# Delete existing rolling nightly release and tag
gh release delete nightly --yes 2>/dev/null || true
git push --delete origin nightly 2>/dev/null || true
# Create new rolling nightly release with all assets
gh release create nightly \
"$ASSETS_DIR"/Donut_nightly_* \
"$ASSETS_DIR"/Donut_aarch64.app.tar.gz \
"$ASSETS_DIR"/Donut_x64.app.tar.gz \
--title "Donut Browser Nightly" \
--notes "Automatically updated nightly build from the latest main branch.\n\nCommit: ${GITHUB_SHA}" \
--prerelease
+3
View File
@@ -131,6 +131,7 @@
"osascript",
"oscpu",
"outpath",
"OVPN",
"patchelf",
"pathex",
"pathlib",
@@ -183,6 +184,7 @@
"stefanzweifel",
"subdirs",
"subkey",
"subsec",
"SUPPRESSMSGBOXES",
"swatinem",
"sysinfo",
@@ -212,6 +214,7 @@
"venv",
"vercel",
"VERYSILENT",
"vpns",
"wayfern",
"webgl",
"webrtc",
+1 -1
View File
@@ -23,6 +23,6 @@ Examples of unacceptable behavior by participants include:
## Enforcement
Violations of the Code of Conduct may be reported to contact at donutbrowser dot com. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
Violations of the Code of Conduct may be reported to [contact@donutbrowser.com](mailto:contact@donutbrowser.com). All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
We hold the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any members for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
+2 -6
View File
@@ -25,11 +25,7 @@
</a>
</p>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/preview-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="assets/preview.png" />
<img alt="Preview" src="assets/preview.png" />
</picture>
<img alt="Donut Browser Preview" src="assets/donut-preview.png" />
## Features
@@ -117,7 +113,7 @@ Have questions or want to contribute? We'd love to hear from you!
## Contact
Have an urgent question or want to report a security vulnerability? Send an email to contact at donutbrowser dot com and we'll get back to you as fast as possible.
Have an urgent question or want to report a security vulnerability? Send an email to [contact@donutbrowser.com](mailto:contact@donutbrowser.com) and we'll get back to you as fast as possible.
## License
+2 -2
View File
@@ -8,7 +8,7 @@ We take the security of Donut Browser seriously. If you believe you have found a
**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.**
Instead, please send an email to **contact at donutbrowser dot com** with the subject line "Security Vulnerability Report".
Instead, please send an email to **[contact@donutbrowser.com](mailto:contact@donutbrowser.com)** with the subject line "Security Vulnerability Report".
Please include as much of the information listed below as you can to help us better understand and resolve the issue:
@@ -32,7 +32,7 @@ This information will help us triage your report more quickly.
## Contact
For urgent security matters, please contact us at **contact at donutbrowser dot com**.
For urgent security matters, please contact us at **[contact@donutbrowser.com](mailto:contact@donutbrowser.com)**.
For general questions about this security policy, you can also reach out through:
Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

+7 -7
View File
@@ -18,12 +18,12 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.990.0",
"@aws-sdk/s3-request-presigner": "^3.990.0",
"@nestjs/common": "^11.1.13",
"@aws-sdk/client-s3": "^3.994.0",
"@aws-sdk/s3-request-presigner": "^3.994.0",
"@nestjs/common": "^11.1.14",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.13",
"@nestjs/platform-express": "^11.1.13",
"@nestjs/core": "^11.1.14",
"@nestjs/platform-express": "^11.1.14",
"jsonwebtoken": "^9.0.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
@@ -31,11 +31,11 @@
"devDependencies": {
"@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "^11.0.9",
"@nestjs/testing": "^11.1.13",
"@nestjs/testing": "^11.1.14",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.2.3",
"@types/node": "^25.3.0",
"@types/supertest": "^6.0.3",
"jest": "^30.2.0",
"source-map-support": "^0.5.21",
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./dist/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+14 -14
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.14.2",
"version": "0.14.6",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
@@ -44,7 +44,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "~2.9.0",
"@tauri-apps/api": "~2.10.1",
"@tauri-apps/plugin-deep-link": "^2.4.7",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "~2.4.5",
@@ -56,9 +56,9 @@
"cmdk": "^1.1.1",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"i18next": "^25.8.7",
"lucide-react": "^0.564.0",
"motion": "^12.34.0",
"i18next": "^25.8.11",
"lucide-react": "^0.575.0",
"motion": "^12.34.2",
"next": "^16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
@@ -68,21 +68,21 @@
"react-icons": "^5.5.0",
"recharts": "3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwind-merge": "^3.5.0",
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
"@biomejs/biome": "2.3.15",
"@tailwindcss/postcss": "^4.1.18",
"@tauri-apps/cli": "~2.9.0",
"@biomejs/biome": "2.4.3",
"@tailwindcss/postcss": "^4.2.0",
"@tauri-apps/cli": "~2.10.0",
"@types/color": "^4.2.0",
"@types/node": "^25.2.3",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"tailwindcss": "^4.1.18",
"tailwindcss": "^4.2.0",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3"
@@ -93,9 +93,9 @@
"biome check --fix"
],
"src-tauri/**/*.rs": [
"cd src-tauri && cargo fmt --all",
"cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all",
"cd src-tauri && cargo test"
"bash -c 'cd src-tauri && cargo fmt --all'",
"bash -c 'cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all'",
"bash -c 'cd src-tauri && cargo test --lib'"
]
}
}
+716 -699
View File
File diff suppressed because it is too large Load Diff
+565 -264
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.14.2"
version = "0.14.6"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -100,6 +100,7 @@ quick-xml = { version = "0.39", features = ["serialize"] }
# VPN support
boringtun = "0.7"
smoltcp = { version = "0.11", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
# Daemon dependencies (tray icon)
tray-icon = "0.21"
+2
View File
@@ -14,6 +14,8 @@
<string>Donut</string>
<key>CFBundleIdentifier</key>
<string>com.donutbrowser</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleURLName</key>
<string>com.donutbrowser</string>
<key>CFBundleExecutable</key>
+6
View File
@@ -601,6 +601,7 @@ async fn create_profile(
&request.version,
request.release_type.as_deref().unwrap_or("stable"),
request.proxy_id.clone(),
None, // vpn_id
camoufox_config,
wayfern_config,
request.group_id.clone(),
@@ -1161,6 +1162,7 @@ async fn delete_proxy(
request_body = RunProfileRequest,
responses(
(status = 200, description = "Profile launched successfully", body = RunProfileResponse),
(status = 400, description = "Cannot launch cross-OS profile"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 500, description = "Internal server error")
@@ -1188,6 +1190,10 @@ async fn run_profile(
.find(|p| p.id.to_string() == id)
.ok_or(StatusCode::NOT_FOUND)?;
if profile.is_cross_os() {
return Err(StatusCode::BAD_REQUEST);
}
// Generate a random port for remote debugging
let remote_debugging_port = rand::random::<u16>().saturating_add(9000).max(9000);
+9 -11
View File
@@ -506,7 +506,8 @@ impl AppAutoUpdater {
&& (asset.name.contains(&format!("_{arch}.dmg"))
|| asset.name.contains(&format!("-{arch}.dmg"))
|| asset.name.contains(&format!("_{arch}_"))
|| asset.name.contains(&format!("-{arch}-")))
|| asset.name.contains(&format!("-{arch}-"))
|| asset.name.contains(&format!("_{arch}-")))
{
log::info!("Found exact architecture match: {}", asset.name);
return Some(asset.browser_download_url.clone());
@@ -564,7 +565,8 @@ impl AppAutoUpdater {
&& (asset.name.contains(&format!("_{arch}.{ext}"))
|| asset.name.contains(&format!("-{arch}.{ext}"))
|| asset.name.contains(&format!("_{arch}_"))
|| asset.name.contains(&format!("-{arch}-")))
|| asset.name.contains(&format!("-{arch}-"))
|| asset.name.contains(&format!("_{arch}-")))
{
log::info!("Found Windows {ext} with exact arch match: {}", asset.name);
return Some(asset.browser_download_url.clone());
@@ -627,7 +629,8 @@ impl AppAutoUpdater {
&& (asset.name.contains(&format!("_{arch}.{ext}"))
|| asset.name.contains(&format!("-{arch}.{ext}"))
|| asset.name.contains(&format!("_{arch}_"))
|| asset.name.contains(&format!("-{arch}-")))
|| asset.name.contains(&format!("-{arch}-"))
|| asset.name.contains(&format!("_{arch}-")))
{
log::info!("Found Linux {ext} with exact arch match: {}", asset.name);
return Some(asset.browser_download_url.clone());
@@ -1698,15 +1701,10 @@ mod tests {
browser_download_url: "https://example.com/x64.dmg".to_string(),
size: 12345,
},
// Windows assets
// Windows assets (NSIS naming: _ARCH-setup.exe)
AppReleaseAsset {
name: "Donut.Browser_0.1.0_x64.msi".to_string(),
browser_download_url: "https://example.com/x64.msi".to_string(),
size: 12345,
},
AppReleaseAsset {
name: "Donut.Browser_0.1.0_x64.exe".to_string(),
browser_download_url: "https://example.com/x64.exe".to_string(),
name: "Donut_0.1.0_x64-setup.exe".to_string(),
browser_download_url: "https://example.com/x64-setup.exe".to_string(),
size: 12345,
},
// Linux assets
+2
View File
@@ -511,6 +511,7 @@ mod tests {
version: version.to_string(),
process_id: None,
proxy_id: None,
vpn_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
@@ -520,6 +521,7 @@ mod tests {
note: None,
sync_enabled: false,
last_sync: None,
host_os: None,
}
}
+2 -5
View File
@@ -243,7 +243,8 @@ fn run_daemon() {
// Use swap to only run cleanup once
if SHOULD_QUIT.swap(false, Ordering::SeqCst) {
// Cleanup
tray::quit_gui();
let mut state = read_state();
state.daemon_pid = None;
let _ = write_state(&state);
@@ -357,10 +358,6 @@ fn main() {
match args[1].as_str() {
"start" => {
// "start" is now an alias for "run"
// On macOS, the daemon should be started via launchctl (see daemon_spawn.rs)
// This command is kept for backward compatibility
eprintln!("Starting daemon...");
run_daemon();
}
"stop" => {
+119
View File
@@ -172,6 +172,24 @@ async fn main() {
)
.arg(Arg::new("action").required(true).help("Action (start)")),
)
.subcommand(
Command::new("vpn-worker")
.about("Run a VPN worker process (internal use)")
.arg(
Arg::new("id")
.long("id")
.required(true)
.help("VPN worker configuration ID"),
)
.arg(
Arg::new("port")
.long("port")
.value_parser(clap::value_parser!(u16))
.required(true)
.help("Local SOCKS5 port"),
)
.arg(Arg::new("action").required(true).help("Action (start)")),
)
.get_matches();
if let Some(proxy_matches) = matches.subcommand_matches("proxy") {
@@ -333,6 +351,107 @@ async fn main() {
log::error!("Invalid action for proxy-worker. Use 'start'");
process::exit(1);
}
} else if let Some(vpn_matches) = matches.subcommand_matches("vpn-worker") {
let id = vpn_matches.get_one::<String>("id").expect("id is required");
let action = vpn_matches
.get_one::<String>("action")
.expect("action is required");
let port = *vpn_matches
.get_one::<u16>("port")
.expect("port is required");
if action == "start" {
set_high_priority();
log::info!("VPN worker starting, config id: {}", id);
log::info!("Process PID: {}", std::process::id());
// Retry config loading to handle file system race condition
let config = {
let mut attempts = 0;
loop {
if let Some(config) = donutbrowser_lib::vpn_worker_storage::get_vpn_worker_config(id) {
log::info!(
"Found VPN worker config: id={}, vpn_type={}, vpn_id={}",
config.id,
config.vpn_type,
config.vpn_id
);
break config;
}
attempts += 1;
if attempts >= 10 {
log::error!(
"VPN worker configuration {} not found after {} attempts",
id,
attempts
);
process::exit(1);
}
log::info!(
"VPN worker config {} not found yet, retrying ({}/10)...",
id,
attempts
);
std::thread::sleep(std::time::Duration::from_millis(50));
}
};
// Read the decrypted VPN config from the temp file
let vpn_config_data = match std::fs::read_to_string(&config.config_file_path) {
Ok(data) => data,
Err(e) => {
log::error!(
"Failed to read VPN config file {}: {}",
config.config_file_path,
e
);
process::exit(1);
}
};
match config.vpn_type.as_str() {
"wireguard" => {
let wg_config = match donutbrowser_lib::vpn::parse_wireguard_config(&vpn_config_data) {
Ok(c) => c,
Err(e) => {
log::error!("Failed to parse WireGuard config: {}", e);
process::exit(1);
}
};
let server =
donutbrowser_lib::vpn::socks5_server::WireGuardSocks5Server::new(wg_config, port);
if let Err(e) = server.run(id.clone()).await {
log::error!("VPN worker failed: {}", e);
process::exit(1);
}
}
"openvpn" => {
let ovpn_config = match donutbrowser_lib::vpn::parse_openvpn_config(&vpn_config_data) {
Ok(c) => c,
Err(e) => {
log::error!("Failed to parse OpenVPN config: {}", e);
process::exit(1);
}
};
let server =
donutbrowser_lib::vpn::openvpn_socks5::OpenVpnSocks5Server::new(ovpn_config, port);
if let Err(e) = server.run(id.clone()).await {
log::error!("VPN worker failed: {}", e);
process::exit(1);
}
}
other => {
log::error!("Unknown VPN type: {}", other);
process::exit(1);
}
}
} else {
log::error!("Invalid action for vpn-worker. Use 'start'");
process::exit(1);
}
} else {
log::error!("No command specified");
process::exit(1);
+18 -2
View File
@@ -612,7 +612,7 @@ mod windows {
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "exe") {
if path.extension().is_some_and(|ext| ext == "exe") && is_pe_executable(&path) {
let name = path
.file_stem()
.unwrap_or_default()
@@ -716,7 +716,7 @@ mod windows {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "exe") {
if path.extension().is_some_and(|ext| ext == "exe") && is_pe_executable(&path) {
let name = path
.file_stem()
.unwrap_or_default()
@@ -1185,6 +1185,22 @@ impl BrowserFactory {
}
}
/// Check if a file is a valid PE executable by reading its magic bytes (MZ).
/// Returns false for archive files (.zip starts with PK, etc.) that were
/// incorrectly named with a .exe extension.
#[cfg(target_os = "windows")]
fn is_pe_executable(path: &Path) -> bool {
use std::io::Read;
let Ok(mut file) = std::fs::File::open(path) else {
return false;
};
let mut magic = [0u8; 2];
if file.read_exact(&mut magic).is_err() {
return false;
}
magic == [0x4D, 0x5A] // MZ
}
// Factory function to create browser instances (kept for backward compatibility)
pub fn create_browser(browser_type: BrowserType) -> Box<dyn Browser> {
BrowserFactory::instance().create_browser(browser_type)
+104 -3
View File
@@ -113,11 +113,34 @@ impl BrowserRunner {
});
// Always start a local proxy for Camoufox (for traffic monitoring and geoip support)
let upstream_proxy = profile
let mut upstream_proxy = profile
.proxy_id
.as_ref()
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
if let Some(ref vpn_id) = profile.vpn_id {
match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await {
Ok(vpn_worker) => {
if let Some(port) = vpn_worker.local_port {
upstream_proxy = Some(ProxySettings {
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port,
username: None,
password: None,
});
log::info!("VPN worker started for Camoufox profile on port {}", port);
}
}
Err(e) => {
return Err(format!("Failed to start VPN worker: {e}").into());
}
}
}
}
log::info!(
"Starting local proxy for Camoufox profile: {} (upstream: {})",
profile.name,
@@ -208,6 +231,14 @@ impl BrowserRunner {
);
}
// Ensure DuckDuckGo is set as default search engine for Camoufox
let mut browser_dir = self.get_binaries_dir();
browser_dir.push(&profile.browser);
browser_dir.push(&profile.version);
if let Err(e) = crate::downloader::configure_camoufox_search_engine(&browser_dir) {
log::warn!("Failed to configure Camoufox search engine: {e}");
}
// Launch Camoufox browser
log::info!("Launching Camoufox for profile: {}", profile.name);
let camoufox_result = self
@@ -312,11 +343,34 @@ impl BrowserRunner {
});
// Always start a local proxy for Wayfern (for traffic monitoring and geoip support)
let upstream_proxy = profile
let mut upstream_proxy = profile
.proxy_id
.as_ref()
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
if let Some(ref vpn_id) = profile.vpn_id {
match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await {
Ok(vpn_worker) => {
if let Some(port) = vpn_worker.local_port {
upstream_proxy = Some(ProxySettings {
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port,
username: None,
password: None,
});
log::info!("VPN worker started for Wayfern profile on port {}", port);
}
}
Err(e) => {
return Err(format!("Failed to start VPN worker: {e}").into());
}
}
}
}
log::info!(
"Starting local proxy for Wayfern profile: {} (upstream: {})",
profile.name,
@@ -2355,6 +2409,14 @@ impl BrowserRunner {
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| format!("Profile '{profile_id}' not found"))?;
if profile.is_cross_os() {
return Err(format!(
"Cannot open URL with profile '{}': it was created on {} and is not supported on this system",
profile.name,
profile.host_os.as_deref().unwrap_or("unknown")
));
}
log::info!("Opening URL '{url}' with profile '{profile_id}'");
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
@@ -2383,6 +2445,14 @@ pub async fn launch_browser_profile(
profile.id
);
if profile.is_cross_os() {
return Err(format!(
"Cannot launch profile '{}': it was created on {} and is not supported on this system",
profile.name,
profile.host_os.as_deref().unwrap_or("unknown")
));
}
let browser_runner = BrowserRunner::instance();
// Store the internal proxy settings for passing to launch_browser
@@ -2413,11 +2483,34 @@ pub async fn launch_browser_profile(
// This ensures all traffic goes through the local proxy for monitoring and future features
if profile.browser != "camoufox" && profile.browser != "wayfern" {
// Determine upstream proxy if configured; otherwise use DIRECT (no upstream)
let upstream_proxy = profile_for_launch
let mut upstream_proxy = profile_for_launch
.proxy_id
.as_ref()
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
if let Some(ref vpn_id) = profile_for_launch.vpn_id {
match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await {
Ok(vpn_worker) => {
if let Some(port) = vpn_worker.local_port {
upstream_proxy = Some(ProxySettings {
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port,
username: None,
password: None,
});
log::info!("VPN worker started for profile on port {}", port);
}
}
Err(e) => {
return Err(format!("Failed to start VPN worker: {e}"));
}
}
}
}
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
let profile_id_str = profile.id.to_string();
@@ -2595,6 +2688,14 @@ pub async fn launch_browser_profile_with_debugging(
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, String> {
if profile.is_cross_os() {
return Err(format!(
"Cannot launch profile '{}': it was created on {} and is not supported on this system",
profile.name,
profile.host_os.as_deref().unwrap_or("unknown")
));
}
let browser_runner = BrowserRunner::instance();
browser_runner
.launch_browser_with_debugging(app_handle, &profile, url, remote_debugging_port, headless)
+1 -1
View File
@@ -685,7 +685,7 @@ impl BrowserVersionManager {
"macos-arm64" | "macos-x64" => (format!("wayfern-{version}-{platform_key}.dmg"), true),
"linux-x64" | "linux-arm64" => (format!("wayfern-{version}-{platform_key}.tar.xz"), true),
"windows-x64" | "windows-arm64" => {
(format!("wayfern-{version}-{platform_key}.exe"), false)
(format!("wayfern-{version}-{platform_key}.zip"), true)
}
_ => {
return Err(
+18 -2
View File
@@ -111,7 +111,15 @@ impl CamoufoxManager {
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// Get executable path
let executable_path = if let Some(path) = &config.executable_path {
PathBuf::from(path)
let p = PathBuf::from(path);
if p.exists() {
p
} else {
log::warn!("Stored Camoufox executable path does not exist: {path}, falling back to dynamic resolution");
BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
}
} else {
BrowserRunner::instance()
.get_browser_executable_path(profile)
@@ -204,7 +212,15 @@ impl CamoufoxManager {
// Get executable path
let executable_path = if let Some(path) = &config.executable_path {
PathBuf::from(path)
let p = PathBuf::from(path);
if p.exists() {
p
} else {
log::warn!("Stored Camoufox executable path does not exist: {path}, falling back to dynamic resolution");
BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
}
} else {
BrowserRunner::instance()
.get_browser_executable_path(profile)
+29 -1
View File
@@ -602,11 +602,39 @@ impl CloudAuthManager {
pub async fn has_active_paid_subscription(&self) -> bool {
let state = self.state.lock().await;
match &*state {
Some(auth) => auth.user.plan != "free" && auth.user.subscription_status == "active",
Some(auth) => {
auth.user.plan != "free"
&& (auth.user.subscription_status == "active"
|| auth.user.plan_period.as_deref() == Some("lifetime"))
}
None => false,
}
}
/// Non-async version that uses try_lock, defaults to false if lock can't be acquired.
pub fn has_active_paid_subscription_sync(&self) -> bool {
match self.state.try_lock() {
Ok(state) => match &*state {
Some(auth) => {
auth.user.plan != "free"
&& (auth.user.subscription_status == "active"
|| auth.user.plan_period.as_deref() == Some("lifetime"))
}
None => false,
},
Err(_) => false,
}
}
pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
let host_os = crate::profile::types::get_host_os();
match fingerprint_os {
None => true,
Some(os) if os == host_os => true,
Some(_) => self.has_active_paid_subscription().await,
}
}
pub async fn get_user(&self) -> Option<CloudAuthState> {
let state = self.state.lock().await;
state.clone()
+22 -7
View File
@@ -103,11 +103,6 @@ pub fn enable_autostart() -> io::Result<()> {
<true/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ProcessType</key>
<string>Interactive</string>
<key>StandardOutPath</key>
@@ -188,6 +183,26 @@ pub fn load_launch_agent() -> io::Result<()> {
Ok(())
}
#[cfg(target_os = "macos")]
pub fn start_launch_agent() -> io::Result<()> {
use std::process::Command;
let output = Command::new("launchctl")
.args(["start", "com.donutbrowser.daemon"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(io::Error::other(format!(
"launchctl start failed: {}",
stderr
)));
}
log::info!("Started launch agent via launchctl");
Ok(())
}
#[cfg(target_os = "macos")]
pub fn unload_launch_agent() -> io::Result<()> {
use std::process::Command;
@@ -233,7 +248,7 @@ pub fn enable_autostart() -> io::Result<()> {
r#"[Desktop Entry]
Type=Application
Name=Donut Browser Daemon
Exec={} start
Exec={} run
Hidden=false
NoDisplay=true
X-GNOME-Autostart-enabled=true
@@ -281,7 +296,7 @@ pub fn enable_autostart() -> io::Result<()> {
key.set_value(
"DonutBrowserDaemon",
&format!("\"{}\" start", daemon_path.display()),
&format!("\"{}\" run", daemon_path.display()),
)?;
log::info!("Added registry autostart entry");
+26
View File
@@ -127,6 +127,32 @@ pub fn activate_gui() {
}
}
pub fn quit_gui() {
log::info!("[daemon] Quitting GUI...");
#[cfg(target_os = "macos")]
{
let _ = Command::new("osascript")
.args(["-e", "tell application \"Donut Browser\" to quit"])
.output();
}
#[cfg(target_os = "windows")]
{
let _ = Command::new("taskkill")
.args(["/IM", "Donut.exe", "/F"])
.output();
let _ = Command::new("taskkill")
.args(["/IM", "donutbrowser.exe", "/F"])
.output();
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("pkill").args(["-x", "donutbrowser"]).output();
}
}
pub fn set_gui_running(running: bool) {
GUI_RUNNING.store(running, Ordering::SeqCst);
}
+6 -1
View File
@@ -32,7 +32,7 @@ fn read_state() -> DaemonState {
DaemonState::default()
}
fn is_daemon_running() -> bool {
pub fn is_daemon_running() -> bool {
let state = read_state();
if let Some(pid) = state.daemon_pid {
@@ -243,6 +243,11 @@ fn spawn_daemon_macos() -> Result<(), String> {
autostart::load_launch_agent().map_err(|e| format!("Failed to load LaunchAgent: {}", e))?;
log::info!("launchctl load completed");
// Also explicitly start the agent in case it was already loaded but stopped
if let Err(e) = autostart::start_launch_agent() {
log::debug!("launchctl start note (non-fatal): {}", e);
}
Ok(())
}
@@ -312,6 +312,30 @@ impl DownloadedBrowsersRegistry {
}
}
// Filter out versions that would leave a browser with zero versions in the registry
{
let data = self.data.lock().unwrap();
let mut removal_counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for (browser, _) in &to_remove {
*removal_counts.entry(browser.clone()).or_insert(0) += 1;
}
to_remove.retain(|(browser, version)| {
let total = data
.browsers
.get(browser.as_str())
.map(|v| v.len())
.unwrap_or(0);
let removing = *removal_counts.get(browser.as_str()).unwrap_or(&0);
if removing >= total {
log::info!("Keeping last available version: {browser} {version}");
*removal_counts.get_mut(browser.as_str()).unwrap() -= 1;
return false;
}
true
});
}
// Remove unused binaries and their version folders
for (browser, version) in to_remove {
if let Err(e) = self.cleanup_failed_download(&browser, &version) {
@@ -1164,6 +1188,58 @@ mod tests {
);
}
#[test]
fn test_last_version_kept_during_cleanup() {
let registry = DownloadedBrowsersRegistry::new();
// Add a single version for "firefox"
registry.add_browser(DownloadedBrowserInfo {
browser: "firefox".to_string(),
version: "139.0".to_string(),
file_path: PathBuf::from("/test/firefox/139.0"),
});
// Add two versions for "chromium"
registry.add_browser(DownloadedBrowserInfo {
browser: "chromium".to_string(),
version: "120.0".to_string(),
file_path: PathBuf::from("/test/chromium/120.0"),
});
registry.add_browser(DownloadedBrowserInfo {
browser: "chromium".to_string(),
version: "121.0".to_string(),
file_path: PathBuf::from("/test/chromium/121.0"),
});
// No active or running profiles
let result = registry
.cleanup_unused_binaries_internal(&[], &[])
.expect("cleanup should succeed");
// firefox 139.0 should be kept (last version), chromium should lose one but keep one
// The exact one kept depends on iteration order, but at least one must remain
assert!(
!result.contains(&"firefox 139.0".to_string()),
"Last version of firefox should not be cleaned up"
);
// At most one chromium version should have been cleaned up
let chromium_cleaned: Vec<_> = result
.iter()
.filter(|r| r.starts_with("chromium"))
.collect();
assert!(
chromium_cleaned.len() <= 1,
"At most one chromium version should be cleaned up, got: {:?}",
chromium_cleaned
);
// Verify firefox is still registered
assert!(
registry.is_browser_registered("firefox", "139.0"),
"Last firefox version should still be registered"
);
}
#[test]
fn test_is_browser_registered_vs_downloaded() {
let registry = DownloadedBrowsersRegistry::new();
+76 -1
View File
@@ -434,6 +434,13 @@ impl Downloader {
Ok(())
}
fn configure_camoufox_search_engine(
&self,
browser_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
configure_camoufox_search_engine(browser_dir)
}
pub async fn download_browser<R: tauri::Runtime>(
&self,
_app_handle: &tauri::AppHandle<R>,
@@ -975,7 +982,10 @@ impl Downloader {
.await
{
log::warn!("Failed to create version.json for Camoufox: {e}");
// Don't fail the download if version.json creation fails
}
if let Err(e) = self.configure_camoufox_search_engine(&browser_dir) {
log::warn!("Failed to configure Camoufox search engine: {e}");
}
}
@@ -1037,6 +1047,71 @@ pub async fn cancel_download(browser_str: String, version: String) -> Result<(),
}
}
/// Clean up the fake "None" search engine from Camoufox policies.json so that
/// Camoufox's built-in fallback (DuckDuckGo when nothing else is configured) can work.
/// Called both at download time and at launch time to cover existing installations.
pub fn configure_camoufox_search_engine(
browser_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let policies_path = browser_dir.join("distribution").join("policies.json");
if !policies_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&policies_path)?;
let mut policies: serde_json::Value = serde_json::from_str(&content)?;
let current_default = policies
.get("policies")
.and_then(|p| p.get("SearchEngines"))
.and_then(|se| se.get("Default"))
.and_then(|d| d.as_str())
.unwrap_or("");
if current_default != "None" {
return Ok(());
}
let mut changed = false;
if let Some(policies_obj) = policies.get_mut("policies") {
if let Some(se) = policies_obj.get_mut("SearchEngines") {
// Remove the fake "None" default so Camoufox uses its built-in fallback
if let Some(obj) = se.as_object_mut() {
obj.remove("Default");
changed = true;
}
// Remove the fake "None" search engine entry from Add
if let Some(add_arr) = se.get_mut("Add").and_then(|a| a.as_array_mut()) {
let before = add_arr.len();
add_arr.retain(|entry| entry.get("Name").and_then(|n| n.as_str()) != Some("None"));
if add_arr.len() != before {
changed = true;
}
}
// Ensure DuckDuckGo is not in the Remove list so it's available as fallback
if let Some(remove_arr) = se.get_mut("Remove").and_then(|r| r.as_array_mut()) {
let before = remove_arr.len();
remove_arr.retain(|v| v.as_str() != Some("DuckDuckGo"));
if remove_arr.len() != before {
changed = true;
}
}
}
}
if changed {
let updated = serde_json::to_string_pretty(&policies)?;
std::fs::write(&policies_path, updated)?;
log::info!("Cleaned up fake 'None' search engine from Camoufox policies.json");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+82 -5
View File
@@ -593,7 +593,11 @@ impl Extractor {
}
}
log::info!("ZIP extraction completed. Searching for executable...");
log::info!("ZIP extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self
.find_extracted_executable(dest_dir)
.await
@@ -617,7 +621,9 @@ impl Extractor {
// Set executable permissions for extracted files
self.set_executable_permissions_recursive(dest_dir).await?;
log::info!("tar.gz extraction completed. Searching for executable...");
log::info!("tar.gz extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -638,7 +644,9 @@ impl Extractor {
// Set executable permissions for extracted files
self.set_executable_permissions_recursive(dest_dir).await?;
log::info!("tar.bz2 extraction completed. Searching for executable...");
log::info!("tar.bz2 extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -673,7 +681,9 @@ impl Extractor {
// Set executable permissions for extracted files
self.set_executable_permissions_recursive(dest_dir).await?;
log::info!("tar.xz extraction completed. Searching for executable...");
log::info!("tar.xz extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -691,7 +701,9 @@ impl Extractor {
extractor.to(dest_dir);
}
log::info!("MSI extraction completed. Searching for executable...");
log::info!("MSI extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -778,6 +790,71 @@ impl Extractor {
self.find_extracted_executable(dest_dir).await
}
fn flatten_single_directory_archive(
&self,
dest_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let entries: Vec<_> = fs::read_dir(dest_dir)?.filter_map(|e| e.ok()).collect();
let archive_extensions = ["zip", "tar", "xz", "gz", "bz2", "dmg", "msi", "exe"];
let mut dirs = Vec::new();
let mut has_non_archive_files = false;
for entry in &entries {
let path = entry.path();
if path.is_dir() {
dirs.push(path);
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if !archive_extensions.contains(&ext.to_lowercase().as_str()) {
has_non_archive_files = true;
}
} else {
has_non_archive_files = true;
}
}
if dirs.len() == 1 && !has_non_archive_files {
let single_dir = &dirs[0];
log::info!(
"Flattening single-directory archive: moving contents of {} to {}",
single_dir.display(),
dest_dir.display()
);
let inner_entries: Vec<_> = fs::read_dir(single_dir)?.filter_map(|e| e.ok()).collect();
for entry in inner_entries {
let source = entry.path();
let file_name = match source.file_name() {
Some(name) => name.to_owned(),
None => continue,
};
let target = dest_dir.join(&file_name);
fs::rename(&source, &target).map_err(|e| {
format!(
"Failed to move {} to {}: {}",
source.display(),
target.display(),
e
)
})?;
}
fs::remove_dir(single_dir).map_err(|e| {
format!(
"Failed to remove empty directory {}: {}",
single_dir.display(),
e
)
})?;
log::info!("Successfully flattened archive directory structure");
}
Ok(())
}
async fn find_extracted_executable(
&self,
dest_dir: &Path,
+2 -1
View File
@@ -119,10 +119,11 @@ impl GroupManager {
return Err(format!("Group with name '{name}' already exists").into());
}
let sync_enabled = crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync();
let group = ProfileGroup {
id: uuid::Uuid::new_v4().to_string(),
name,
sync_enabled: false,
sync_enabled,
last_sync: None,
};
+191 -70
View File
@@ -49,6 +49,8 @@ mod mcp_server;
mod tag_manager;
mod version_updater;
pub mod vpn;
pub mod vpn_worker_runner;
pub mod vpn_worker_storage;
use browser_runner::{
check_browser_exists, kill_browser_profile, launch_browser_profile, open_url_with_profile,
@@ -57,7 +59,7 @@ use browser_runner::{
use profile::manager::{
check_browser_status, clone_profile, create_browser_profile_new, delete_profile,
list_browser_profiles, rename_profile, update_camoufox_config, update_profile_note,
update_profile_proxy, update_profile_tags, update_wayfern_config,
update_profile_proxy, update_profile_tags, update_profile_vpn, update_wayfern_config,
};
use browser_version_manager::{
@@ -80,8 +82,9 @@ use settings_manager::{
};
use sync::{
is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile, request_profile_sync,
set_group_sync_enabled, set_profile_sync_enabled, set_proxy_sync_enabled,
enable_sync_for_all_entities, get_unsynced_entity_counts, is_group_in_use_by_synced_profile,
is_proxy_in_use_by_synced_profile, is_vpn_in_use_by_synced_profile, request_profile_sync,
set_group_sync_enabled, set_profile_sync_enabled, set_proxy_sync_enabled, set_vpn_sync_enabled,
};
use tag_manager::get_all_tags;
@@ -469,69 +472,142 @@ async fn get_vpn_config(vpn_id: String) -> Result<vpn::VpnConfig, String> {
}
#[tauri::command]
async fn delete_vpn_config(vpn_id: String) -> Result<(), String> {
// First disconnect if connected
async fn delete_vpn_config(app_handle: tauri::AppHandle, vpn_id: String) -> Result<(), String> {
// First disconnect if connected (stop VPN worker)
let _ = vpn_worker_runner::stop_vpn_worker_by_vpn_id(&vpn_id).await;
// Check if sync was enabled before deleting
let was_sync_enabled = {
let storage = vpn::VPN_STORAGE
.lock()
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
storage
.load_config(&vpn_id)
.map(|c| c.sync_enabled)
.unwrap_or(false)
};
// Delete from storage
{
let mut manager = vpn::TUNNEL_MANAGER.lock().await;
if manager.is_tunnel_active(&vpn_id) {
if let Some(tunnel) = manager.get_tunnel_mut(&vpn_id) {
let _ = tunnel.disconnect().await;
}
manager.remove_tunnel(&vpn_id);
}
}
// Then delete from storage
let storage = vpn::VPN_STORAGE
.lock()
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
storage
.delete_config(&vpn_id)
.map_err(|e| format!("Failed to delete VPN config: {e}"))
}
#[tauri::command]
async fn connect_vpn(vpn_id: String) -> Result<(), String> {
// Load config from storage
let config = {
let storage = vpn::VPN_STORAGE
.lock()
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
storage
.load_config(&vpn_id)
.map_err(|e| format!("Failed to load VPN config: {e}"))?
};
// Create and connect the appropriate tunnel
let mut manager = vpn::TUNNEL_MANAGER.lock().await;
// Check if already connected
if manager.is_tunnel_active(&vpn_id) {
return Ok(());
.delete_config(&vpn_id)
.map_err(|e| format!("Failed to delete VPN config: {e}"))?;
}
let mut tunnel: Box<dyn vpn::VpnTunnel> = match config.vpn_type {
vpn::VpnType::WireGuard => {
let wg_config = vpn::parse_wireguard_config(&config.config_data)
.map_err(|e| format!("Invalid WireGuard config: {e}"))?;
Box::new(vpn::WireGuardTunnel::new(vpn_id.clone(), wg_config))
// If sync was enabled, also delete from remote
if was_sync_enabled {
let vpn_id_clone = vpn_id.clone();
let app_handle_clone = app_handle.clone();
tauri::async_runtime::spawn(async move {
match sync::SyncEngine::create_from_settings(&app_handle_clone).await {
Ok(engine) => {
if let Err(e) = engine.delete_vpn(&vpn_id_clone).await {
log::warn!("Failed to delete VPN {} from sync: {}", vpn_id_clone, e);
} else {
log::info!("VPN {} deleted from sync storage", vpn_id_clone);
}
}
Err(e) => {
log::debug!("Sync not configured, skipping remote VPN deletion: {}", e);
}
}
});
}
let _ = events::emit("vpn-configs-changed", ());
Ok(())
}
#[tauri::command]
async fn create_vpn_config_manual(
name: String,
vpn_type: vpn::VpnType,
config_data: String,
) -> Result<vpn::VpnConfig, String> {
let storage = vpn::VPN_STORAGE
.lock()
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
storage
.create_config_manual(&name, vpn_type, &config_data)
.map_err(|e| format!("Failed to create VPN config: {e}"))
}
#[tauri::command]
async fn update_vpn_config(vpn_id: String, name: String) -> Result<vpn::VpnConfig, String> {
let storage = vpn::VPN_STORAGE
.lock()
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
storage
.update_config_name(&vpn_id, &name)
.map_err(|e| format!("Failed to update VPN config: {e}"))
}
#[tauri::command]
async fn check_vpn_validity(
vpn_id: String,
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Start a temporary VPN worker to send real traffic
let vpn_worker = vpn_worker_runner::start_vpn_worker(&vpn_id)
.await
.map_err(|e| format!("Failed to start VPN worker: {e}"))?;
let socks_url = format!("socks5://127.0.0.1:{}", vpn_worker.local_port.unwrap_or(0));
// Fetch public IP through the VPN SOCKS5 proxy
let result = match ip_utils::fetch_public_ip(Some(&socks_url)).await {
Ok(ip) => {
let (city, country, country_code) =
crate::proxy_manager::ProxyManager::get_ip_geolocation(&ip)
.await
.unwrap_or_default();
crate::proxy_manager::ProxyCheckResult {
ip,
city,
country,
country_code,
timestamp: now,
is_valid: true,
}
}
vpn::VpnType::OpenVPN => {
let ovpn_config = vpn::parse_openvpn_config(&config.config_data)
.map_err(|e| format!("Invalid OpenVPN config: {e}"))?;
Box::new(vpn::OpenVpnTunnel::new(vpn_id.clone(), ovpn_config))
Err(e) => {
log::warn!("VPN check failed to fetch public IP: {e}");
crate::proxy_manager::ProxyCheckResult {
ip: String::new(),
city: None,
country: None,
country_code: None,
timestamp: now,
is_valid: false,
}
}
};
tunnel
.connect()
// Stop the temporary VPN worker
let _ = vpn_worker_runner::stop_vpn_worker(&vpn_worker.id).await;
Ok(result)
}
#[tauri::command]
async fn connect_vpn(vpn_id: String) -> Result<(), String> {
// Start VPN worker process (detached, survives GUI shutdown)
vpn_worker_runner::start_vpn_worker(&vpn_id)
.await
.map_err(|e| format!("Failed to connect VPN: {e}"))?;
manager.register_tunnel(vpn_id.clone(), tunnel);
// Update last_used timestamp
{
let storage = vpn::VPN_STORAGE
@@ -545,27 +621,27 @@ async fn connect_vpn(vpn_id: String) -> Result<(), String> {
#[tauri::command]
async fn disconnect_vpn(vpn_id: String) -> Result<(), String> {
let mut manager = vpn::TUNNEL_MANAGER.lock().await;
if let Some(tunnel) = manager.get_tunnel_mut(&vpn_id) {
tunnel
.disconnect()
.await
.map_err(|e| format!("Failed to disconnect VPN: {e}"))?;
}
manager.remove_tunnel(&vpn_id);
vpn_worker_runner::stop_vpn_worker_by_vpn_id(&vpn_id)
.await
.map_err(|e| format!("Failed to disconnect VPN: {e}"))?;
Ok(())
}
#[tauri::command]
async fn get_vpn_status(vpn_id: String) -> Result<vpn::VpnStatus, String> {
let manager = vpn::TUNNEL_MANAGER.lock().await;
use crate::proxy_storage::is_process_running;
if let Some(tunnel) = manager.get_tunnel(&vpn_id) {
Ok(tunnel.get_status())
if let Some(worker) = vpn_worker_storage::find_vpn_worker_by_vpn_id(&vpn_id) {
let connected = worker.pid.map(is_process_running).unwrap_or(false);
Ok(vpn::VpnStatus {
connected,
vpn_id,
connected_at: None,
bytes_sent: None,
bytes_received: None,
last_handshake: None,
})
} else {
// Not connected
Ok(vpn::VpnStatus {
connected: false,
vpn_id,
@@ -579,8 +655,23 @@ async fn get_vpn_status(vpn_id: String) -> Result<vpn::VpnStatus, String> {
#[tauri::command]
async fn list_active_vpn_connections() -> Result<Vec<vpn::VpnStatus>, String> {
let manager = vpn::TUNNEL_MANAGER.lock().await;
Ok(manager.get_all_statuses())
use crate::proxy_storage::is_process_running;
let workers = vpn_worker_storage::list_vpn_worker_configs();
Ok(
workers
.into_iter()
.filter(|w| w.pid.map(is_process_running).unwrap_or(false))
.map(|w| vpn::VpnStatus {
connected: true,
vpn_id: w.vpn_id,
connected_at: None,
bytes_sent: None,
bytes_received: None,
last_handshake: None,
})
.collect(),
)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -645,6 +736,30 @@ pub fn run() {
log::warn!("Failed to start daemon: {e}");
}
// Monitor daemon health - quit GUI if daemon dies
let app_handle_daemon = app.handle().clone();
tauri::async_runtime::spawn(async move {
// Give the daemon time to fully start
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
interval.tick().await;
let is_running = tokio::task::spawn_blocking(daemon_spawn::is_daemon_running)
.await
.unwrap_or(false);
if !is_running {
log::warn!("Daemon is no longer running, quitting GUI");
app_handle_daemon.exit(0);
break;
}
}
});
// Create the main window programmatically
#[allow(unused_variables)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
@@ -1124,6 +1239,7 @@ pub fn run() {
get_all_tags,
get_browser_release_types,
update_profile_proxy,
update_profile_vpn,
update_profile_tags,
update_profile_note,
check_browser_status,
@@ -1191,6 +1307,10 @@ pub fn run() {
set_group_sync_enabled,
is_proxy_in_use_by_synced_profile,
is_group_in_use_by_synced_profile,
set_vpn_sync_enabled,
is_vpn_in_use_by_synced_profile,
get_unsynced_entity_counts,
enable_sync_for_all_entities,
read_profile_cookies,
copy_profile_cookies,
check_wayfern_terms_accepted,
@@ -1208,6 +1328,9 @@ pub fn run() {
list_vpn_configs,
get_vpn_config,
delete_vpn_config,
create_vpn_config_manual,
update_vpn_config,
check_vpn_validity,
connect_vpn,
disconnect_vpn,
get_vpn_status,
@@ -1247,12 +1370,10 @@ mod tests {
// Commands that are intentionally not used in the frontend
// but are used via MCP server or other programmatic APIs
let mcp_only_commands = [
"list_vpn_configs",
"get_vpn_config",
"delete_vpn_config",
"connect_vpn",
"disconnect_vpn",
"get_vpn_status",
"get_vpn_config",
"list_active_vpn_connections",
];
+27 -84
View File
@@ -1702,16 +1702,8 @@ impl McpServer {
message: "Missing vpn_id".to_string(),
})?;
// First disconnect if connected
{
let mut manager = crate::vpn::TUNNEL_MANAGER.lock().await;
if manager.is_tunnel_active(vpn_id) {
if let Some(tunnel) = manager.get_tunnel_mut(vpn_id) {
let _ = tunnel.disconnect().await;
}
manager.remove_tunnel(vpn_id);
}
}
// First disconnect if connected (stop VPN worker)
let _ = crate::vpn_worker_runner::stop_vpn_worker_by_vpn_id(vpn_id).await;
let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError {
code: -32000,
@@ -1743,63 +1735,14 @@ impl McpServer {
message: "Missing vpn_id".to_string(),
})?;
// Load config from storage
let config = {
let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError {
// Start VPN worker process
crate::vpn_worker_runner::start_vpn_worker(vpn_id)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to lock VPN storage: {e}"),
message: format!("Failed to connect VPN: {e}"),
})?;
storage.load_config(vpn_id).map_err(|e| McpError {
code: -32000,
message: format!("Failed to load VPN config: {e}"),
})?
};
let mut manager = crate::vpn::TUNNEL_MANAGER.lock().await;
// Check if already connected
if manager.is_tunnel_active(vpn_id) {
return Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("VPN '{}' is already connected", config.name)
}]
}));
}
let mut tunnel: Box<dyn crate::vpn::VpnTunnel> = match config.vpn_type {
crate::vpn::VpnType::WireGuard => {
let wg_config =
crate::vpn::parse_wireguard_config(&config.config_data).map_err(|e| McpError {
code: -32000,
message: format!("Invalid WireGuard config: {e}"),
})?;
Box::new(crate::vpn::WireGuardTunnel::new(
vpn_id.to_string(),
wg_config,
))
}
crate::vpn::VpnType::OpenVPN => {
let ovpn_config =
crate::vpn::parse_openvpn_config(&config.config_data).map_err(|e| McpError {
code: -32000,
message: format!("Invalid OpenVPN config: {e}"),
})?;
Box::new(crate::vpn::OpenVpnTunnel::new(
vpn_id.to_string(),
ovpn_config,
))
}
};
tunnel.connect().await.map_err(|e| McpError {
code: -32000,
message: format!("Failed to connect VPN: {e}"),
})?;
manager.register_tunnel(vpn_id.to_string(), tunnel);
// Update last_used timestamp
{
let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError {
@@ -1812,7 +1755,7 @@ impl McpServer {
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("VPN '{}' connected successfully", config.name)
"text": format!("VPN '{}' connected successfully", vpn_id)
}]
}))
}
@@ -1829,16 +1772,12 @@ impl McpServer {
message: "Missing vpn_id".to_string(),
})?;
let mut manager = crate::vpn::TUNNEL_MANAGER.lock().await;
if let Some(tunnel) = manager.get_tunnel_mut(vpn_id) {
tunnel.disconnect().await.map_err(|e| McpError {
crate::vpn_worker_runner::stop_vpn_worker_by_vpn_id(vpn_id)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to disconnect VPN: {e}"),
})?;
}
manager.remove_tunnel(vpn_id);
Ok(serde_json::json!({
"content": [{
@@ -1860,19 +1799,23 @@ impl McpServer {
message: "Missing vpn_id".to_string(),
})?;
let manager = crate::vpn::TUNNEL_MANAGER.lock().await;
let connected =
if let Some(worker) = crate::vpn_worker_storage::find_vpn_worker_by_vpn_id(vpn_id) {
worker
.pid
.map(crate::proxy_storage::is_process_running)
.unwrap_or(false)
} else {
false
};
let status = if let Some(tunnel) = manager.get_tunnel(vpn_id) {
tunnel.get_status()
} else {
crate::vpn::VpnStatus {
connected: false,
vpn_id: vpn_id.to_string(),
connected_at: None,
bytes_sent: None,
bytes_received: None,
last_handshake: None,
}
let status = crate::vpn::VpnStatus {
connected,
vpn_id: vpn_id.to_string(),
connected_at: None,
bytes_sent: None,
bytes_received: None,
last_handshake: None,
};
Ok(serde_json::json!({
+108 -6
View File
@@ -3,7 +3,7 @@ use crate::browser::{create_browser, BrowserType, ProxySettings};
use crate::camoufox_manager::CamoufoxConfig;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::events;
use crate::profile::types::BrowserProfile;
use crate::profile::types::{get_host_os, BrowserProfile};
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::WayfernConfig;
use directories::BaseDirs;
@@ -61,10 +61,14 @@ impl ProfileManager {
version: &str,
release_type: &str,
proxy_id: Option<String>,
vpn_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
group_id: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
if proxy_id.is_some() && vpn_id.is_some() {
return Err("Cannot set both proxy_id and vpn_id".into());
}
log::info!("Attempting to create profile: {name}");
// Check if a profile with this name already exists (case insensitive)
@@ -163,6 +167,7 @@ impl ProfileManager {
browser: browser.to_string(),
version: version.to_string(),
proxy_id: proxy_id.clone(),
vpn_id: None,
process_id: None,
last_launch: None,
release_type: release_type.to_string(),
@@ -173,6 +178,7 @@ impl ProfileManager {
note: None,
sync_enabled: false,
last_sync: None,
host_os: None,
};
match self
@@ -276,6 +282,7 @@ impl ProfileManager {
browser: browser.to_string(),
version: version.to_string(),
proxy_id: proxy_id.clone(),
vpn_id: None,
process_id: None,
last_launch: None,
release_type: release_type.to_string(),
@@ -286,6 +293,7 @@ impl ProfileManager {
note: None,
sync_enabled: false,
last_sync: None,
host_os: None,
};
match self
@@ -321,6 +329,7 @@ impl ProfileManager {
browser: browser.to_string(),
version: version.to_string(),
proxy_id: proxy_id.clone(),
vpn_id: vpn_id.clone(),
process_id: None,
last_launch: None,
release_type: release_type.to_string(),
@@ -331,6 +340,7 @@ impl ProfileManager {
note: None,
sync_enabled: false,
last_sync: None,
host_os: Some(get_host_os()),
};
// Save profile info
@@ -466,8 +476,8 @@ impl ProfileManager {
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Check if browser is running
if profile.process_id.is_some() {
// Check if browser is running (cross-OS profiles can't be running locally)
if profile.process_id.is_some() && !profile.is_cross_os() {
return Err(
"Cannot delete profile while browser is running. Please stop the browser first.".into(),
);
@@ -733,8 +743,8 @@ impl ProfileManager {
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Check if browser is running
if profile.process_id.is_some() {
// Check if browser is running (cross-OS profiles can't be running locally)
if profile.process_id.is_some() && !profile.is_cross_os() {
return Err(
format!(
"Cannot delete profile '{}' while browser is running. Please stop the browser first.",
@@ -837,6 +847,7 @@ impl ProfileManager {
browser: source.browser,
version: source.version,
proxy_id: source.proxy_id,
vpn_id: source.vpn_id,
process_id: None,
last_launch: None,
release_type: source.release_type,
@@ -847,6 +858,7 @@ impl ProfileManager {
note: source.note,
sync_enabled: false,
last_sync: None,
host_os: Some(get_host_os()),
};
self.save_profile(&new_profile)?;
@@ -1007,8 +1019,9 @@ impl ProfileManager {
// Remember old proxy_id for cleanup (not used yet, but may be needed for cleanup)
let _old_proxy_id = profile.proxy_id.clone();
// Update proxy settings
// Update proxy settings and clear VPN (mutual exclusion)
profile.proxy_id = proxy_id.clone();
profile.vpn_id = None;
// Save the updated profile
self
@@ -1071,6 +1084,52 @@ impl ProfileManager {
Ok(profile)
}
pub async fn update_profile_vpn(
&self,
_app_handle: tauri::AppHandle,
profile_id: &str,
vpn_id: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
|_| -> Box<dyn std::error::Error + Send + Sync> {
format!("Invalid profile ID: {profile_id}").into()
},
)?;
let profiles =
self
.list_profiles()
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to list profiles: {e}").into()
})?;
let mut profile = profiles
.into_iter()
.find(|p| p.id == profile_uuid)
.ok_or_else(|| -> Box<dyn std::error::Error + Send + Sync> {
format!("Profile with ID '{profile_id}' not found").into()
})?;
// Update VPN and clear proxy (mutual exclusion)
profile.vpn_id = vpn_id;
profile.proxy_id = None;
self
.save_profile(&profile)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to save profile: {e}").into()
})?;
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
}
pub async fn check_browser_status(
&self,
app_handle: tauri::AppHandle,
@@ -1795,6 +1854,7 @@ pub async fn create_browser_profile_with_group(
version: String,
release_type: String,
proxy_id: Option<String>,
vpn_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
group_id: Option<String>,
@@ -1808,6 +1868,7 @@ pub async fn create_browser_profile_with_group(
&version,
&release_type,
proxy_id,
vpn_id,
camoufox_config,
wayfern_config,
group_id,
@@ -1837,6 +1898,19 @@ pub async fn update_profile_proxy(
.map_err(|e| format!("Failed to update profile: {e}"))
}
#[tauri::command]
pub async fn update_profile_vpn(
app_handle: tauri::AppHandle,
profile_id: String,
vpn_id: Option<String>,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
.update_profile_vpn(app_handle, &profile_id, vpn_id)
.await
.map_err(|e| format!("Failed to update profile VPN: {e}"))
}
#[tauri::command]
pub fn update_profile_tags(
app_handle: tauri::AppHandle,
@@ -1894,10 +1968,23 @@ pub async fn create_browser_profile_new(
version: String,
release_type: String,
proxy_id: Option<String>,
vpn_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
group_id: Option<String>,
) -> Result<BrowserProfile, String> {
let fingerprint_os = camoufox_config
.as_ref()
.and_then(|c| c.os.as_deref())
.or_else(|| wayfern_config.as_ref().and_then(|c| c.os.as_deref()));
if !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(fingerprint_os)
.await
{
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
}
let browser_type =
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
create_browser_profile_with_group(
@@ -1907,6 +1994,7 @@ pub async fn create_browser_profile_new(
version,
release_type,
proxy_id,
vpn_id,
camoufox_config,
wayfern_config,
group_id,
@@ -1920,6 +2008,13 @@ pub async fn update_camoufox_config(
profile_id: String,
config: CamoufoxConfig,
) -> Result<(), String> {
if !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(config.os.as_deref())
.await
{
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
}
let profile_manager = ProfileManager::instance();
profile_manager
.update_camoufox_config(app_handle, &profile_id, config)
@@ -1933,6 +2028,13 @@ pub async fn update_wayfern_config(
profile_id: String,
config: WayfernConfig,
) -> Result<(), String> {
if !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(config.os.as_deref())
.await
{
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
}
let profile_manager = ProfileManager::instance();
profile_manager
.update_wayfern_config(app_handle, &profile_id, config)
+23
View File
@@ -22,6 +22,8 @@ pub struct BrowserProfile {
#[serde(default)]
pub proxy_id: Option<String>, // Reference to stored proxy
#[serde(default)]
pub vpn_id: Option<String>, // Reference to stored VPN config
#[serde(default)]
pub process_id: Option<u32>,
#[serde(default)]
pub last_launch: Option<u64>,
@@ -41,15 +43,36 @@ pub struct BrowserProfile {
pub sync_enabled: bool, // Whether sync is enabled for this profile
#[serde(default)]
pub last_sync: Option<u64>, // Timestamp of last successful sync (epoch seconds)
#[serde(default)]
pub host_os: Option<String>, // OS where profile was created ("macos", "windows", "linux")
}
pub fn default_release_type() -> String {
"stable".to_string()
}
pub fn get_host_os() -> String {
if cfg!(target_os = "macos") {
"macos".to_string()
} else if cfg!(target_os = "windows") {
"windows".to_string()
} else {
"linux".to_string()
}
}
impl BrowserProfile {
/// Get the path to the profile data directory (profiles/{uuid}/profile)
pub fn get_profile_data_path(&self, profiles_dir: &Path) -> PathBuf {
profiles_dir.join(self.id.to_string()).join("profile")
}
/// Returns true when the profile was created on a different OS than the current host.
/// Profiles without an `os` field (backward compat) are treated as native.
pub fn is_cross_os(&self) -> bool {
match &self.host_os {
Some(host_os) => host_os != &get_host_os(),
None => false,
}
}
}
+2
View File
@@ -545,6 +545,7 @@ impl ProfileImporter {
browser: browser_type.to_string(),
version: available_versions,
proxy_id: None,
vpn_id: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
@@ -555,6 +556,7 @@ impl ProfileImporter {
note: None,
sync_enabled: false,
last_sync: None,
host_os: Some(crate::profile::types::get_host_os()),
};
// Save the profile metadata
+27 -5
View File
@@ -117,11 +117,12 @@ pub struct StoredProxy {
impl StoredProxy {
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
let sync_enabled = crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync();
Self {
id: uuid::Uuid::new_v4().to_string(),
name,
proxy_settings,
sync_enabled: false,
sync_enabled,
last_sync: None,
is_cloud_managed: false,
is_cloud_derived: false,
@@ -243,8 +244,7 @@ impl ProxyManager {
.as_secs()
}
// Get geolocation for an IP address
async fn get_ip_geolocation(
pub async fn get_ip_geolocation(
ip: &str,
) -> Result<(Option<String>, Option<String>, Option<String>), String> {
// Use ip-api.com (free, no API key required)
@@ -478,15 +478,16 @@ impl ProxyManager {
}
// Build a geo-targeted username from base username and location parts
// LP format: username-zone-lightning-region-{country}-st-{state}-city-{city}
fn build_geo_username(
base_username: &str,
country: &str,
state: &Option<String>,
city: &Option<String>,
) -> String {
let mut username = format!("{}-country-{}", base_username, country);
let mut username = format!("{}-zone-lightning-region-{}", base_username, country);
if let Some(state) = state {
username = format!("{}-state-{}", username, state);
username = format!("{}-st-{}", username, state);
}
if let Some(city) = city {
username = format!("{}-city-{}", username, city);
@@ -1625,6 +1626,27 @@ impl ProxyManager {
delete_proxy_config(&config.id);
}
// Clean up orphaned VPN worker configs where the worker process is dead
{
use crate::proxy_storage::is_process_running;
use crate::vpn_worker_storage::{delete_vpn_worker_config, list_vpn_worker_configs};
let vpn_workers = list_vpn_worker_configs();
for worker in vpn_workers {
if let Some(pid) = worker.pid {
if !is_process_running(pid) {
log::info!(
"Cleaning up orphaned VPN worker config: {} (process PID {} is dead)",
worker.id,
pid
);
let _ = std::fs::remove_file(&worker.config_file_path);
delete_vpn_worker_config(&worker.id);
}
}
}
}
// Emit event for reactive UI updates
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
+493 -5
View File
@@ -59,6 +59,15 @@ impl SyncEngine {
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> SyncResult<()> {
if profile.is_cross_os() {
log::info!(
"Skipping file sync for cross-OS profile: {} ({})",
profile.name,
profile.id
);
return Ok(());
}
let profile_manager = ProfileManager::instance();
let profiles_dir = profile_manager.get_profiles_dir();
let profile_dir = profiles_dir.join(profile.id.to_string());
@@ -93,6 +102,24 @@ impl SyncEngine {
// Generate local manifest
let local_manifest = generate_manifest(&profile_id, &profile_dir, &mut hash_cache)?;
let total_size: u64 = local_manifest.files.iter().map(|f| f.size).sum();
let has_cookies = local_manifest
.files
.iter()
.any(|f| f.path.contains("Cookies") || f.path.contains("cookies"));
let has_local_state = local_manifest
.files
.iter()
.any(|f| f.path.contains("Local State"));
log::info!(
"Profile {} manifest: {} files, {} bytes total, cookies={}, local_state={}",
profile_id,
local_manifest.files.len(),
total_size,
has_cookies,
has_local_state
);
// Save the hash cache for future runs
hash_cache.save(&cache_path)?;
@@ -165,13 +192,16 @@ impl SyncEngine {
// Upload manifest.json last for atomicity
self.upload_manifest(&profile_id, &local_manifest).await?;
// Sync associated proxy and group
// Sync associated proxy, group, and VPN
if let Some(proxy_id) = &profile.proxy_id {
let _ = self.sync_proxy(proxy_id, Some(app_handle)).await;
}
if let Some(group_id) = &profile.group_id {
let _ = self.sync_group(group_id, Some(app_handle)).await;
}
if let Some(vpn_id) = &profile.vpn_id {
let _ = self.sync_vpn(vpn_id, Some(app_handle)).await;
}
// Update profile last_sync
let mut updated_profile = profile.clone();
@@ -776,6 +806,145 @@ impl SyncEngine {
Ok(())
}
async fn sync_vpn(&self, vpn_id: &str, app_handle: Option<&tauri::AppHandle>) -> SyncResult<()> {
let local_vpn = {
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage.load_config(vpn_id).ok()
};
let remote_key = format!("vpns/{}.json", vpn_id);
let stat = self.client.stat(&remote_key).await?;
match (local_vpn, stat.exists) {
(Some(vpn), true) => {
let local_updated = vpn.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
if remote_ts > local_updated {
self.download_vpn(vpn_id, app_handle).await?;
} else if local_updated > remote_ts {
self.upload_vpn(&vpn).await?;
}
}
(Some(vpn), false) => {
self.upload_vpn(&vpn).await?;
}
(None, true) => {
self.download_vpn(vpn_id, app_handle).await?;
}
(None, false) => {
log::debug!("VPN {} not found locally or remotely", vpn_id);
}
}
Ok(())
}
async fn upload_vpn(&self, vpn: &crate::vpn::VpnConfig) -> SyncResult<()> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let mut updated_vpn = vpn.clone();
updated_vpn.last_sync = Some(now);
let json = serde_json::to_string_pretty(&updated_vpn)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize VPN: {e}")))?;
let remote_key = format!("vpns/{}.json", vpn.id);
let presign = self
.client
.presign_upload(&remote_key, Some("application/json"))
.await?;
self
.client
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
.await?;
// Update local VPN with new last_sync
{
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
if let Err(e) = storage.update_sync_fields(&vpn.id, vpn.sync_enabled, Some(now)) {
log::warn!("Failed to update VPN last_sync: {}", e);
}
}
log::info!("VPN {} uploaded", vpn.id);
Ok(())
}
async fn download_vpn(
&self,
vpn_id: &str,
app_handle: Option<&tauri::AppHandle>,
) -> SyncResult<()> {
let remote_key = format!("vpns/{}.json", vpn_id);
let presign = self.client.presign_download(&remote_key).await?;
let data = self.client.download_bytes(&presign.url).await?;
let mut vpn: crate::vpn::VpnConfig = serde_json::from_slice(&data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse VPN JSON: {e}")))?;
vpn.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
vpn.sync_enabled = true;
// Save via VPN storage (handles encryption)
{
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
if let Err(e) = storage.save_config(&vpn) {
log::warn!("Failed to save downloaded VPN: {}", e);
}
}
// Emit event for UI update
if let Some(_handle) = app_handle {
let _ = events::emit("vpn-configs-changed", ());
let _ = events::emit(
"vpn-sync-status",
serde_json::json!({
"id": vpn_id,
"status": "synced"
}),
);
}
log::info!("VPN {} downloaded", vpn_id);
Ok(())
}
pub async fn sync_vpn_by_id_with_handle(
&self,
vpn_id: &str,
app_handle: &tauri::AppHandle,
) -> SyncResult<()> {
self.sync_vpn(vpn_id, Some(app_handle)).await
}
pub async fn delete_vpn(&self, vpn_id: &str) -> SyncResult<()> {
let remote_key = format!("vpns/{}.json", vpn_id);
let tombstone_key = format!("tombstones/vpns/{}.json", vpn_id);
self
.client
.delete(&remote_key, Some(&tombstone_key))
.await?;
log::info!("VPN {} deleted from sync", vpn_id);
Ok(())
}
/// Download a profile from S3 if it exists remotely but not locally
pub async fn download_profile_if_missing(
&self,
@@ -832,6 +1001,49 @@ impl SyncEngine {
let mut profile: BrowserProfile = serde_json::from_slice(&metadata_data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse metadata: {e}")))?;
// Cross-OS profile: save metadata only, skip manifest + file downloads
if profile.is_cross_os() {
log::info!(
"Profile {} is cross-OS (host_os={:?}), downloading metadata only",
profile_id,
profile.host_os
);
fs::create_dir_all(&profile_dir).map_err(|e| {
SyncError::IoError(format!(
"Failed to create profile directory {}: {e}",
profile_dir.display()
))
})?;
profile.sync_enabled = true;
profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
profile_manager
.save_profile(&profile)
.map_err(|e| SyncError::IoError(format!("Failed to save cross-OS profile: {e}")))?;
let _ = events::emit("profiles-changed", ());
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "synced"
}),
);
log::info!(
"Cross-OS profile {} metadata downloaded successfully",
profile_id
);
return Ok(true);
}
// Download manifest
let manifest = self.download_manifest(&manifest_key).await?;
let Some(manifest) = manifest else {
@@ -849,6 +1061,21 @@ impl SyncEngine {
})?;
// Download all files from manifest
let total_size: u64 = manifest.files.iter().map(|f| f.size).sum();
log::info!(
"Profile {} recovery: downloading {} files ({} bytes total)",
profile_id,
manifest.files.len(),
total_size
);
for file in &manifest.files {
log::info!(
" -> {} ({} bytes, hash: {})",
file.path,
file.size,
file.hash
);
}
if !manifest.files.is_empty() {
self
.download_profile_files(app_handle, profile_id, &profile_dir, &manifest.files)
@@ -940,6 +1167,57 @@ impl SyncEngine {
log::info!("No missing profiles found");
}
// Refresh metadata for local cross-OS profiles (propagate renames, tags, notes from originating device)
let profile_manager = ProfileManager::instance();
// Collect cross-OS profiles before async operations to avoid holding non-Send Result across await
let cross_os_profiles: Vec<(String, bool)> = profile_manager
.list_profiles()
.unwrap_or_default()
.iter()
.filter(|p| p.is_cross_os() && p.sync_enabled)
.map(|p| (p.id.to_string(), p.sync_enabled))
.collect();
if !cross_os_profiles.is_empty() {
for (pid, sync_enabled) in &cross_os_profiles {
let metadata_key = format!("profiles/{}/metadata.json", pid);
match self.client.stat(&metadata_key).await {
Ok(stat) if stat.exists => match self.client.presign_download(&metadata_key).await {
Ok(presign) => match self.client.download_bytes(&presign.url).await {
Ok(data) => {
if let Ok(mut remote_profile) = serde_json::from_slice::<BrowserProfile>(&data) {
remote_profile.sync_enabled = *sync_enabled;
remote_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
if let Err(e) = profile_manager.save_profile(&remote_profile) {
log::warn!("Failed to refresh cross-OS profile {} metadata: {}", pid, e);
} else {
log::debug!("Refreshed cross-OS profile {} metadata", pid);
}
}
}
Err(e) => {
log::warn!(
"Failed to download cross-OS profile {} metadata: {}",
pid,
e
);
}
},
Err(e) => {
log::warn!("Failed to presign cross-OS profile {} metadata: {}", pid, e);
}
},
_ => {}
}
}
let _ = events::emit("profiles-changed", ());
}
Ok(downloaded)
}
}
@@ -997,6 +1275,43 @@ pub async fn enable_proxy_sync_if_needed(
Ok(())
}
/// Check if VPN is used by any synced profile
pub fn is_vpn_used_by_synced_profile(vpn_id: &str) -> bool {
let profile_manager = ProfileManager::instance();
if let Ok(profiles) = profile_manager.list_profiles() {
profiles
.iter()
.any(|p| p.sync_enabled && p.vpn_id.as_deref() == Some(vpn_id))
} else {
false
}
}
/// Enable sync for VPN if not already enabled
pub async fn enable_vpn_sync_if_needed(
vpn_id: &str,
_app_handle: &tauri::AppHandle,
) -> Result<(), String> {
let vpn = {
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.load_config(vpn_id)
.map_err(|e| format!("VPN with ID '{vpn_id}' not found: {e}"))?
};
if !vpn.sync_enabled {
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.update_sync_fields(vpn_id, true, None)
.map_err(|e| format!("Failed to enable VPN sync: {e}"))?;
let _ = events::emit("vpn-configs-changed", ());
log::info!("Auto-enabled sync for VPN {}", vpn_id);
}
Ok(())
}
/// Enable sync for group if not already enabled
pub async fn enable_group_sync_if_needed(
group_id: &str,
@@ -1048,6 +1363,10 @@ pub async fn set_profile_sync_enabled(
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
if profile.is_cross_os() {
return Err("Cannot modify sync settings for a cross-OS profile".to_string());
}
// If enabling, first check that sync settings are configured
if enabled {
// Cloud auth provides sync settings dynamically — skip local checks
@@ -1090,10 +1409,6 @@ pub async fn set_profile_sync_enabled(
profile.sync_enabled = enabled;
if !enabled {
profile.last_sync = None;
}
profile_manager
.save_profile(&profile)
.map_err(|e| format!("Failed to save profile: {e}"))?;
@@ -1133,6 +1448,13 @@ pub async fn set_profile_sync_enabled(
scheduler.queue_group_sync(group_id.clone()).await;
}
}
if let Some(ref vpn_id) = profile.vpn_id {
if let Err(e) = enable_vpn_sync_if_needed(vpn_id, &app_handle).await {
log::warn!("Failed to enable sync for VPN {}: {}", vpn_id, e);
} else {
scheduler.queue_vpn_sync(vpn_id.clone()).await;
}
}
} else {
log::warn!("Scheduler not initialized, sync will not start");
}
@@ -1419,3 +1741,169 @@ pub fn is_proxy_in_use_by_synced_profile(proxy_id: String) -> bool {
pub fn is_group_in_use_by_synced_profile(group_id: String) -> bool {
is_group_used_by_synced_profile(&group_id)
}
#[tauri::command]
pub async fn set_vpn_sync_enabled(
app_handle: tauri::AppHandle,
vpn_id: String,
enabled: bool,
) -> Result<(), String> {
let vpn = {
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.load_config(&vpn_id)
.map_err(|e| format!("VPN with ID '{vpn_id}' not found: {e}"))?
};
// If disabling, check if VPN is used by any synced profile
if !enabled && is_vpn_used_by_synced_profile(&vpn_id) {
return Err("Sync cannot be disabled while this VPN is used by synced profiles".to_string());
}
// If enabling, check that sync settings are configured
if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
return Err(
"Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
}
}
let last_sync = if enabled { vpn.last_sync } else { None };
{
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.update_sync_fields(&vpn_id, enabled, last_sync)
.map_err(|e| format!("Failed to update VPN sync: {e}"))?;
}
let _ = events::emit("vpn-configs-changed", ());
if enabled {
let _ = events::emit(
"vpn-sync-status",
serde_json::json!({
"id": vpn_id,
"status": "syncing"
}),
);
if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_vpn_sync(vpn_id).await;
}
} else {
let _ = events::emit(
"vpn-sync-status",
serde_json::json!({
"id": vpn_id,
"status": "disabled"
}),
);
}
Ok(())
}
#[tauri::command]
pub fn is_vpn_in_use_by_synced_profile(vpn_id: String) -> bool {
is_vpn_used_by_synced_profile(&vpn_id)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct UnsyncedEntityCounts {
pub proxies: usize,
pub groups: usize,
pub vpns: usize,
}
#[tauri::command]
pub fn get_unsynced_entity_counts() -> Result<UnsyncedEntityCounts, String> {
let proxy_count = {
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
proxies
.iter()
.filter(|p| !p.sync_enabled && !p.is_cloud_managed)
.count()
};
let group_count = {
let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
let groups = gm
.get_all_groups()
.map_err(|e| format!("Failed to get groups: {e}"))?;
groups.iter().filter(|g| !g.sync_enabled).count()
};
let vpn_count = {
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
let configs = storage
.list_configs()
.map_err(|e| format!("Failed to list VPN configs: {e}"))?;
configs.iter().filter(|c| !c.sync_enabled).count()
};
Ok(UnsyncedEntityCounts {
proxies: proxy_count,
groups: group_count,
vpns: vpn_count,
})
}
#[tauri::command]
pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> {
// Enable sync for all unsynced proxies
{
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
for proxy in &proxies {
if !proxy.sync_enabled && !proxy.is_cloud_managed {
set_proxy_sync_enabled(app_handle.clone(), proxy.id.clone(), true).await?;
}
}
}
// Enable sync for all unsynced groups
{
let groups = {
let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
gm.get_all_groups()
.map_err(|e| format!("Failed to get groups: {e}"))?
};
for group in &groups {
if !group.sync_enabled {
set_group_sync_enabled(app_handle.clone(), group.id.clone(), true).await?;
}
}
}
// Enable sync for all unsynced VPNs
{
let configs = {
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.list_configs()
.map_err(|e| format!("Failed to list VPN configs: {e}"))?
};
for config in &configs {
if !config.sync_enabled {
set_vpn_sync_enabled(app_handle.clone(), config.id.clone(), true).await?;
}
}
}
Ok(())
}
+5 -4
View File
@@ -7,11 +7,12 @@ pub mod types;
pub use client::SyncClient;
pub use engine::{
enable_group_sync_if_needed, enable_proxy_sync_if_needed, is_group_in_use_by_synced_profile,
enable_group_sync_if_needed, enable_proxy_sync_if_needed, enable_sync_for_all_entities,
enable_vpn_sync_if_needed, get_unsynced_entity_counts, is_group_in_use_by_synced_profile,
is_group_used_by_synced_profile, is_proxy_in_use_by_synced_profile,
is_proxy_used_by_synced_profile, request_profile_sync, set_group_sync_enabled,
set_profile_sync_enabled, set_proxy_sync_enabled, sync_profile, trigger_sync_for_profile,
SyncEngine,
is_proxy_used_by_synced_profile, is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile,
request_profile_sync, set_group_sync_enabled, set_profile_sync_enabled, set_proxy_sync_enabled,
set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine,
};
pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest};
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
+84
View File
@@ -34,6 +34,7 @@ pub struct SyncScheduler {
pending_profiles: Arc<Mutex<HashMap<String, ProfileStopTime>>>,
pending_proxies: Arc<Mutex<HashSet<String>>>,
pending_groups: Arc<Mutex<HashSet<String>>>,
pending_vpns: Arc<Mutex<HashSet<String>>>,
pending_tombstones: Arc<Mutex<Vec<(String, String)>>>,
running_profiles: Arc<Mutex<HashSet<String>>>,
in_flight_profiles: Arc<Mutex<HashSet<String>>>,
@@ -52,6 +53,7 @@ impl SyncScheduler {
pending_profiles: Arc::new(Mutex::new(HashMap::new())),
pending_proxies: Arc::new(Mutex::new(HashSet::new())),
pending_groups: Arc::new(Mutex::new(HashSet::new())),
pending_vpns: Arc::new(Mutex::new(HashSet::new())),
pending_tombstones: Arc::new(Mutex::new(Vec::new())),
running_profiles: Arc::new(Mutex::new(HashSet::new())),
in_flight_profiles: Arc::new(Mutex::new(HashSet::new())),
@@ -92,6 +94,12 @@ impl SyncScheduler {
}
drop(pending_groups);
let pending_vpns = self.pending_vpns.lock().await;
if !pending_vpns.is_empty() {
return true;
}
drop(pending_vpns);
let pending_tombstones = self.pending_tombstones.lock().await;
if !pending_tombstones.is_empty() {
return true;
@@ -190,6 +198,11 @@ impl SyncScheduler {
pending.insert(proxy_id);
}
pub async fn queue_vpn_sync(&self, vpn_id: String) {
let mut pending = self.pending_vpns.lock().await;
pending.insert(vpn_id);
}
pub async fn queue_group_sync(&self, group_id: String) {
let mut pending = self.pending_groups.lock().await;
pending.insert(group_id);
@@ -269,6 +282,7 @@ impl SyncScheduler {
SyncWorkItem::Profile(id) => scheduler.queue_profile_sync(id).await,
SyncWorkItem::Proxy(id) => scheduler.queue_proxy_sync(id).await,
SyncWorkItem::Group(id) => scheduler.queue_group_sync(id).await,
SyncWorkItem::Vpn(id) => scheduler.queue_vpn_sync(id).await,
SyncWorkItem::Tombstone(entity_type, entity_id) => {
scheduler.queue_tombstone(entity_type, entity_id).await
}
@@ -288,6 +302,7 @@ impl SyncScheduler {
self.process_pending_profiles(app_handle).await;
self.process_pending_proxies(app_handle).await;
self.process_pending_groups(app_handle).await;
self.process_pending_vpns(app_handle).await;
self.process_pending_tombstones(app_handle).await;
}
@@ -366,6 +381,7 @@ impl SyncScheduler {
&& self.pending_profiles.lock().await.is_empty()
&& self.pending_proxies.lock().await.is_empty()
&& self.pending_groups.lock().await.is_empty()
&& self.pending_vpns.lock().await.is_empty()
};
match result {
@@ -537,6 +553,68 @@ impl SyncScheduler {
}
}
async fn process_pending_vpns(&self, app_handle: &tauri::AppHandle) {
let vpns_to_sync: Vec<String> = {
let mut pending = self.pending_vpns.lock().await;
let list: Vec<String> = pending.drain().collect();
list
};
if vpns_to_sync.is_empty() {
return;
}
match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => {
for vpn_id in vpns_to_sync {
log::info!("Syncing VPN {}", vpn_id);
let _ = events::emit(
"vpn-sync-status",
serde_json::json!({
"id": vpn_id,
"status": "syncing"
}),
);
match engine.sync_vpn_by_id_with_handle(&vpn_id, app_handle).await {
Ok(()) => {
let _ = events::emit(
"vpn-sync-status",
serde_json::json!({
"id": vpn_id,
"status": "synced"
}),
);
}
Err(e) => {
log::error!("Failed to sync VPN {}: {}", vpn_id, e);
let _ = events::emit(
"vpn-sync-status",
serde_json::json!({
"id": vpn_id,
"status": "error"
}),
);
}
}
}
if !self.is_sync_in_progress().await {
log::debug!("All syncs completed after VPN sync, triggering cleanup");
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::warn!("Cleanup after sync failed: {e}");
} else {
log::debug!("Cleanup after sync completed successfully");
}
}
}
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
}
}
}
async fn process_pending_tombstones(&self, app_handle: &tauri::AppHandle) {
let tombstones: Vec<(String, String)> = {
let mut pending = self.pending_tombstones.lock().await;
@@ -607,6 +685,12 @@ impl SyncScheduler {
entity_id
);
}
"vpn" => {
log::debug!(
"VPN tombstone for {} - local deletion not implemented",
entity_id
);
}
_ => {}
}
}
+11
View File
@@ -23,6 +23,7 @@ pub enum SyncWorkItem {
Profile(String),
Proxy(String),
Group(String),
Vpn(String),
Tombstone(String, String),
}
@@ -229,6 +230,11 @@ impl SyncSubscription {
.strip_prefix("groups/")
.and_then(|s| s.strip_suffix(".json"))
.map(|s| SyncWorkItem::Group(s.to_string()))
} else if key.starts_with("vpns/") {
key
.strip_prefix("vpns/")
.and_then(|s| s.strip_suffix(".json"))
.map(|s| SyncWorkItem::Vpn(s.to_string()))
} else if key.starts_with("tombstones/") {
key.strip_prefix("tombstones/").and_then(|rest| {
if rest.starts_with("profiles/") {
@@ -246,6 +252,11 @@ impl SyncSubscription {
.strip_prefix("groups/")
.and_then(|s| s.strip_suffix(".json"))
.map(|id| SyncWorkItem::Tombstone("group".to_string(), id.to_string()))
} else if rest.starts_with("vpns/") {
rest
.strip_prefix("vpns/")
.and_then(|s| s.strip_suffix(".json"))
.map(|id| SyncWorkItem::Tombstone("vpn".to_string(), id.to_string()))
} else {
None
}
+4
View File
@@ -52,6 +52,10 @@ pub struct VpnConfig {
pub config_data: String, // Raw config content (encrypted at rest)
pub created_at: i64,
pub last_used: Option<i64>,
#[serde(default)]
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
}
/// Parsed WireGuard configuration
+2 -4
View File
@@ -7,6 +7,8 @@
mod config;
mod openvpn;
pub mod openvpn_socks5;
pub mod socks5_server;
mod storage;
mod tunnel;
mod wireguard;
@@ -25,7 +27,3 @@ use std::sync::Mutex;
/// Global VPN storage instance
pub static VPN_STORAGE: Lazy<Mutex<VpnStorage>> = Lazy::new(|| Mutex::new(VpnStorage::new()));
/// Global tunnel manager instance
pub static TUNNEL_MANAGER: Lazy<tokio::sync::Mutex<TunnelManager>> =
Lazy::new(|| tokio::sync::Mutex::new(TunnelManager::new()));
+233
View File
@@ -0,0 +1,233 @@
use super::config::{OpenVpnConfig, VpnError};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
pub struct OpenVpnSocks5Server {
config: OpenVpnConfig,
port: u16,
}
impl OpenVpnSocks5Server {
pub fn new(config: OpenVpnConfig, port: u16) -> Self {
Self { config, port }
}
fn find_openvpn_binary() -> Result<PathBuf, VpnError> {
let locations = [
"/usr/sbin/openvpn",
"/usr/local/sbin/openvpn",
"/opt/homebrew/bin/openvpn",
"/usr/bin/openvpn",
"C:\\Program Files\\OpenVPN\\bin\\openvpn.exe",
"C:\\Program Files (x86)\\OpenVPN\\bin\\openvpn.exe",
];
for loc in &locations {
let path = PathBuf::from(loc);
if path.exists() {
return Ok(path);
}
}
#[cfg(unix)]
{
if let Ok(output) = Command::new("which").arg("openvpn").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Ok(PathBuf::from(path));
}
}
}
}
#[cfg(windows)]
{
if let Ok(output) = Command::new("where").arg("openvpn").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
if !path.is_empty() {
return Ok(PathBuf::from(path));
}
}
}
}
Err(VpnError::Connection(
"OpenVPN binary not found. Please install OpenVPN.".to_string(),
))
}
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
let openvpn_bin = Self::find_openvpn_binary()?;
// Write config to temp file
let config_path = std::env::temp_dir().join(format!("openvpn_{}.ovpn", config_id));
std::fs::write(&config_path, &self.config.raw_config).map_err(VpnError::Io)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600));
}
// Find a management port
let mgmt_listener = std::net::TcpListener::bind("127.0.0.1:0")
.map_err(|e| VpnError::Connection(format!("Failed to bind management port: {e}")))?;
let mgmt_port = mgmt_listener
.local_addr()
.map_err(|e| VpnError::Connection(format!("Failed to get management port: {e}")))?
.port();
drop(mgmt_listener);
// Start OpenVPN with SOCKS proxy mode
let mut cmd = Command::new(&openvpn_bin);
cmd
.arg("--config")
.arg(&config_path)
.arg("--management")
.arg("127.0.0.1")
.arg(mgmt_port.to_string())
.arg("--socks-proxy")
.arg("127.0.0.1")
.arg(self.port.to_string())
.arg("--verb")
.arg("3")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| VpnError::Connection(format!("Failed to start OpenVPN: {e}")))?;
// Wait for OpenVPN to start
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
match child.try_wait() {
Ok(Some(status)) => {
let _ = std::fs::remove_file(&config_path);
return Err(VpnError::Connection(format!(
"OpenVPN exited early with status: {status}. OpenVPN requires elevated privileges (sudo/admin)."
)));
}
Ok(None) => {}
Err(e) => {
let _ = std::fs::remove_file(&config_path);
return Err(VpnError::Connection(format!(
"Failed to check OpenVPN status: {e}"
)));
}
}
// Start a basic SOCKS5 proxy that tunnels through the OpenVPN TUN interface
let listener = TcpListener::bind(format!("127.0.0.1:{}", self.port))
.await
.map_err(|e| VpnError::Connection(format!("Failed to bind SOCKS5: {e}")))?;
let actual_port = listener
.local_addr()
.map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))?
.port();
if let Some(mut wc) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) {
wc.local_port = Some(actual_port);
wc.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
let _ = crate::vpn_worker_storage::save_vpn_worker_config(&wc);
}
log::info!(
"[vpn-worker] OpenVPN SOCKS5 server listening on 127.0.0.1:{}",
actual_port
);
loop {
match listener.accept().await {
Ok((client, _)) => {
tokio::spawn(Self::handle_socks5_client(client));
}
Err(e) => {
log::warn!("[vpn-worker] Accept error: {e}");
}
}
}
}
async fn handle_socks5_client(
mut client: TcpStream,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// SOCKS5 greeting
let mut buf = [0u8; 256];
let n = client.read(&mut buf).await?;
if n < 3 || buf[0] != 0x05 {
return Ok(());
}
client.write_all(&[0x05, 0x00]).await?;
// SOCKS5 connect request
let n = client.read(&mut buf).await?;
if n < 10 || buf[0] != 0x05 || buf[1] != 0x01 {
return Ok(());
}
let dest_addr = match buf[3] {
0x01 => {
let ip = std::net::Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]);
let port = u16::from_be_bytes([buf[8], buf[9]]);
format!("{}:{}", ip, port)
}
0x03 => {
let domain_len = buf[4] as usize;
let domain = String::from_utf8_lossy(&buf[5..5 + domain_len]).to_string();
let port_start = 5 + domain_len;
let port = u16::from_be_bytes([buf[port_start], buf[port_start + 1]]);
format!("{}:{}", domain, port)
}
_ => return Ok(()),
};
// Connect to destination through OpenVPN tunnel (OS routing handles it)
match TcpStream::connect(&dest_addr).await {
Ok(upstream) => {
client
.write_all(&[0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 0])
.await?;
let (mut cr, mut cw) = client.into_split();
let (mut ur, mut uw) = upstream.into_split();
let c2u = tokio::io::copy(&mut cr, &mut uw);
let u2c = tokio::io::copy(&mut ur, &mut cw);
let _ = tokio::try_join!(c2u, u2c);
}
Err(_) => {
client
.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_openvpn_binary_format() {
let result = OpenVpnSocks5Server::find_openvpn_binary();
match result {
Ok(path) => assert!(!path.as_os_str().is_empty()),
Err(e) => assert!(e.to_string().contains("not found")),
}
}
}
+656
View File
@@ -0,0 +1,656 @@
use super::config::{VpnError, WireGuardConfig};
use boringtun::noise::{Tunn, TunnResult};
use boringtun::x25519::{PublicKey, StaticSecret};
use smoltcp::iface::{Config as IfaceConfig, Interface, SocketHandle, SocketSet};
use smoltcp::phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken};
use smoltcp::socket::tcp::{Socket as TcpSocket, SocketBuffer};
use smoltcp::time::Instant as SmolInstant;
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, Ipv4Address};
use std::collections::VecDeque;
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
use std::sync::{Arc, Mutex};
use tokio::net::{TcpListener, TcpStream};
const SMOLTCP_TCP_RX_BUF: usize = 65536;
const SMOLTCP_TCP_TX_BUF: usize = 65536;
struct WgDevice {
tunn: Arc<Mutex<Box<Tunn>>>,
udp_socket: Arc<UdpSocket>,
peer_addr: SocketAddr,
rx_queue: VecDeque<Vec<u8>>,
tx_queue: VecDeque<Vec<u8>>,
}
impl WgDevice {
fn pump_wg_to_rx(&mut self) {
let mut recv_buf = vec![0u8; 2048];
loop {
match self.udp_socket.recv_from(&mut recv_buf) {
Ok((len, _)) => {
let mut dst = vec![0u8; 2048];
let mut tunn = self.tunn.lock().unwrap();
let result = tunn.decapsulate(None, &recv_buf[..len], &mut dst);
match result {
TunnResult::WriteToTunnelV4(data, _) | TunnResult::WriteToTunnelV6(data, _) => {
self.rx_queue.push_back(data.to_vec());
}
TunnResult::WriteToNetwork(response) => {
let _ = self.udp_socket.send_to(response, self.peer_addr);
}
_ => {}
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
Err(_) => break,
}
}
}
fn flush_tx_queue(&mut self) {
while let Some(ip_packet) = self.tx_queue.pop_front() {
let mut dst = vec![0u8; ip_packet.len() + 256];
let mut tunn = self.tunn.lock().unwrap();
let result = tunn.encapsulate(&ip_packet, &mut dst);
if let TunnResult::WriteToNetwork(packet) = result {
let _ = self.udp_socket.send_to(packet, self.peer_addr);
}
}
}
fn tick_timers(&mut self) {
let mut dst = vec![0u8; 2048];
let mut tunn = self.tunn.lock().unwrap();
let result = tunn.update_timers(&mut dst);
if let TunnResult::WriteToNetwork(packet) = result {
let _ = self.udp_socket.send_to(packet, self.peer_addr);
}
}
}
struct WgRxToken {
data: Vec<u8>,
}
impl RxToken for WgRxToken {
fn consume<R, F>(mut self, f: F) -> R
where
F: FnOnce(&mut [u8]) -> R,
{
f(&mut self.data)
}
}
struct WgTxToken<'a> {
tx_queue: &'a mut VecDeque<Vec<u8>>,
}
impl<'a> TxToken for WgTxToken<'a> {
fn consume<R, F>(self, len: usize, f: F) -> R
where
F: FnOnce(&mut [u8]) -> R,
{
let mut buf = vec![0u8; len];
let result = f(&mut buf);
self.tx_queue.push_back(buf);
result
}
}
impl Device for WgDevice {
type RxToken<'a> = WgRxToken;
type TxToken<'a> = WgTxToken<'a>;
fn receive(&mut self, _timestamp: SmolInstant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
if let Some(data) = self.rx_queue.pop_front() {
Some((
WgRxToken { data },
WgTxToken {
tx_queue: &mut self.tx_queue,
},
))
} else {
None
}
}
fn transmit(&mut self, _timestamp: SmolInstant) -> Option<Self::TxToken<'_>> {
Some(WgTxToken {
tx_queue: &mut self.tx_queue,
})
}
fn capabilities(&self) -> DeviceCapabilities {
let mut caps = DeviceCapabilities::default();
caps.medium = Medium::Ip;
caps.max_transmission_unit = 1420;
caps
}
}
fn parse_key(key: &str) -> Result<[u8; 32], VpnError> {
let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, key)
.map_err(|e| VpnError::InvalidWireGuard(format!("Invalid key encoding: {e}")))?;
if decoded.len() != 32 {
return Err(VpnError::InvalidWireGuard(format!(
"Invalid key length: {} (expected 32)",
decoded.len()
)));
}
let mut key_bytes = [0u8; 32];
key_bytes.copy_from_slice(&decoded);
Ok(key_bytes)
}
fn parse_cidr_address(addr: &str) -> Result<(IpCidr, IpAddress), VpnError> {
let first_addr = addr.split(',').next().unwrap_or(addr).trim();
let parts: Vec<&str> = first_addr.split('/').collect();
let ip_str = parts[0];
let prefix = if parts.len() > 1 {
parts[1]
.parse::<u8>()
.map_err(|_| VpnError::InvalidWireGuard(format!("Invalid prefix length: {}", parts[1])))?
} else {
32
};
let ip: std::net::IpAddr = ip_str
.parse()
.map_err(|_| VpnError::InvalidWireGuard(format!("Invalid IP address: {ip_str}")))?;
match ip {
std::net::IpAddr::V4(v4) => {
let smol_ip = Ipv4Address::new(
v4.octets()[0],
v4.octets()[1],
v4.octets()[2],
v4.octets()[3],
);
Ok((
IpCidr::new(IpAddress::Ipv4(smol_ip), prefix),
IpAddress::Ipv4(smol_ip),
))
}
std::net::IpAddr::V6(v6) => {
let smol_ip = smoltcp::wire::Ipv6Address::from_bytes(&v6.octets());
Ok((
IpCidr::new(IpAddress::Ipv6(smol_ip), prefix),
IpAddress::Ipv6(smol_ip),
))
}
}
}
pub struct WireGuardSocks5Server {
config: WireGuardConfig,
port: u16,
}
impl WireGuardSocks5Server {
pub fn new(config: WireGuardConfig, port: u16) -> Self {
Self { config, port }
}
fn create_tunnel(&self) -> Result<Box<Tunn>, VpnError> {
let private_key_bytes = parse_key(&self.config.private_key)?;
let static_private = StaticSecret::from(private_key_bytes);
let peer_public_bytes = parse_key(&self.config.peer_public_key)?;
let peer_public = PublicKey::from(peer_public_bytes);
let preshared_key = if let Some(ref psk) = self.config.preshared_key {
Some(parse_key(psk)?)
} else {
None
};
Ok(Box::new(Tunn::new(
static_private,
peer_public,
preshared_key,
self.config.persistent_keepalive,
0,
None,
)))
}
fn resolve_endpoint(&self) -> Result<SocketAddr, VpnError> {
self
.config
.peer_endpoint
.to_socket_addrs()
.map_err(|e| {
VpnError::Connection(format!(
"Failed to resolve endpoint '{}': {e}",
self.config.peer_endpoint
))
})?
.next()
.ok_or_else(|| {
VpnError::Connection(format!(
"No addresses found for endpoint: {}",
self.config.peer_endpoint
))
})
}
fn do_handshake(
tunn: &mut Tunn,
socket: &UdpSocket,
peer_addr: SocketAddr,
) -> Result<(), VpnError> {
let mut dst = vec![0u8; 2048];
let result = tunn.format_handshake_initiation(&mut dst, false);
match result {
TunnResult::WriteToNetwork(packet) => {
socket
.send_to(packet, peer_addr)
.map_err(|e| VpnError::Connection(format!("Failed to send handshake: {e}")))?;
}
TunnResult::Err(e) => {
return Err(VpnError::Tunnel(format!(
"Handshake initiation failed: {e:?}"
)));
}
_ => {}
}
socket
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
.map_err(|e| VpnError::Connection(format!("Failed to set timeout: {e}")))?;
let mut recv_buf = vec![0u8; 2048];
match socket.recv_from(&mut recv_buf) {
Ok((len, _)) => {
let result = tunn.decapsulate(None, &recv_buf[..len], &mut dst);
match result {
TunnResult::WriteToNetwork(response) => {
socket
.send_to(response, peer_addr)
.map_err(|e| VpnError::Connection(format!("Failed to send response: {e}")))?;
}
TunnResult::Done => {}
TunnResult::Err(e) => {
return Err(VpnError::Tunnel(format!(
"Handshake response failed: {e:?}"
)));
}
_ => {}
}
}
Err(e) => {
return Err(VpnError::Connection(format!(
"Handshake timeout or error: {e}"
)));
}
}
socket
.set_read_timeout(None)
.map_err(|e| VpnError::Connection(format!("Failed to clear timeout: {e}")))?;
Ok(())
}
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
let peer_addr = self.resolve_endpoint()?;
let mut tunn = self.create_tunnel()?;
let udp_socket = UdpSocket::bind("0.0.0.0:0")
.map_err(|e| VpnError::Connection(format!("Failed to create UDP socket: {e}")))?;
Self::do_handshake(&mut tunn, &udp_socket, peer_addr)?;
udp_socket
.set_nonblocking(true)
.map_err(|e| VpnError::Connection(format!("Failed to set non-blocking: {e}")))?;
log::info!("[vpn-worker] WireGuard handshake completed");
let (cidr, local_ip) = parse_cidr_address(&self.config.address)?;
let tunn_arc = Arc::new(Mutex::new(tunn));
let udp_arc = Arc::new(udp_socket);
let mut device = WgDevice {
tunn: tunn_arc.clone(),
udp_socket: udp_arc.clone(),
peer_addr,
rx_queue: VecDeque::new(),
tx_queue: VecDeque::new(),
};
let iface_config = IfaceConfig::new(HardwareAddress::Ip);
let mut iface = Interface::new(iface_config, &mut device, SmolInstant::now());
iface.update_ip_addrs(|addrs| {
let _ = addrs.push(cidr);
});
// Set default gateway
match local_ip {
IpAddress::Ipv4(v4) => {
let octets = v4.as_bytes();
let gw = Ipv4Address::new(octets[0], octets[1], octets[2], 1);
iface
.routes_mut()
.add_default_ipv4_route(gw)
.map_err(|e| VpnError::Tunnel(format!("Failed to add default route: {e}")))?;
}
IpAddress::Ipv6(_) => {
// IPv6 routing not yet implemented
}
}
let listener = TcpListener::bind(format!("127.0.0.1:{}", self.port))
.await
.map_err(|e| VpnError::Connection(format!("Failed to bind SOCKS5 listener: {e}")))?;
let actual_port = listener
.local_addr()
.map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))?
.port();
// Update config with actual port and local_url
if let Some(mut wc) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) {
wc.local_port = Some(actual_port);
wc.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
let _ = crate::vpn_worker_storage::save_vpn_worker_config(&wc);
}
log::info!(
"[vpn-worker] SOCKS5 server listening on 127.0.0.1:{}",
actual_port
);
let mut sockets = SocketSet::new(vec![]);
struct Connection {
smol_handle: SocketHandle,
tcp_stream: TcpStream,
socks_done: bool,
read_buf: Vec<u8>,
dest_addr: Option<SocketAddr>,
}
let mut connections: Vec<Connection> = Vec::new();
let mut timer_counter: u64 = 0;
loop {
// Accept new SOCKS5 connections (non-blocking via short timeout)
if let Ok(Ok((stream, _addr))) =
tokio::time::timeout(tokio::time::Duration::from_millis(1), listener.accept()).await
{
let tcp_rx = SocketBuffer::new(vec![0u8; SMOLTCP_TCP_RX_BUF]);
let tcp_tx = SocketBuffer::new(vec![0u8; SMOLTCP_TCP_TX_BUF]);
let tcp_socket = TcpSocket::new(tcp_rx, tcp_tx);
let handle = sockets.add(tcp_socket);
connections.push(Connection {
smol_handle: handle,
tcp_stream: stream,
socks_done: false,
read_buf: Vec::new(),
dest_addr: None,
});
}
// Pump WireGuard packets into smoltcp rx queue
device.pump_wg_to_rx();
// Poll the smoltcp interface
let timestamp = SmolInstant::now();
let _changed = iface.poll(timestamp, &mut device, &mut sockets);
// Flush encrypted packets out through WireGuard
device.flush_tx_queue();
// Process each connection
let mut completed = Vec::new();
for (idx, conn) in connections.iter_mut().enumerate() {
if !conn.socks_done {
// Handle SOCKS5 handshake
let mut buf = [0u8; 512];
match conn.tcp_stream.try_read(&mut buf) {
Ok(0) => {
completed.push(idx);
continue;
}
Ok(n) => {
conn.read_buf.extend_from_slice(&buf[..n]);
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(_) => {
completed.push(idx);
continue;
}
}
if conn.dest_addr.is_none() && conn.read_buf.len() >= 3 {
// SOCKS5 greeting: version, nmethods, methods
if conn.read_buf[0] != 0x05 {
completed.push(idx);
continue;
}
// Reply: no auth required
let _ = conn.tcp_stream.try_write(&[0x05, 0x00]);
let nmethods = conn.read_buf[1] as usize;
conn.read_buf.drain(..2 + nmethods);
}
if conn.dest_addr.is_none() && conn.read_buf.len() >= 10 {
// SOCKS5 connect request
if conn.read_buf[0] != 0x05 || conn.read_buf[1] != 0x01 {
completed.push(idx);
continue;
}
let (addr, addr_len) = match conn.read_buf[3] {
0x01 => {
// IPv4
if conn.read_buf.len() < 10 {
continue;
}
let ip = std::net::Ipv4Addr::new(
conn.read_buf[4],
conn.read_buf[5],
conn.read_buf[6],
conn.read_buf[7],
);
let port = u16::from_be_bytes([conn.read_buf[8], conn.read_buf[9]]);
(SocketAddr::new(std::net::IpAddr::V4(ip), port), 10)
}
0x03 => {
// Domain name
let domain_len = conn.read_buf[4] as usize;
let needed = 4 + 1 + domain_len + 2;
if conn.read_buf.len() < needed {
continue;
}
let domain = String::from_utf8_lossy(&conn.read_buf[5..5 + domain_len]).to_string();
let port_start = 5 + domain_len;
let port =
u16::from_be_bytes([conn.read_buf[port_start], conn.read_buf[port_start + 1]]);
// Resolve domain
match format!("{}:{}", domain, port).to_socket_addrs() {
Ok(mut addrs) => {
if let Some(addr) = addrs.next() {
(addr, needed)
} else {
// Send SOCKS5 error: host unreachable
let _ = conn
.tcp_stream
.try_write(&[0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
completed.push(idx);
continue;
}
}
Err(_) => {
let _ = conn
.tcp_stream
.try_write(&[0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
completed.push(idx);
continue;
}
}
}
0x04 => {
// IPv6
if conn.read_buf.len() < 22 {
continue;
}
let mut octets = [0u8; 16];
octets.copy_from_slice(&conn.read_buf[4..20]);
let ip = std::net::Ipv6Addr::from(octets);
let port = u16::from_be_bytes([conn.read_buf[20], conn.read_buf[21]]);
(SocketAddr::new(std::net::IpAddr::V6(ip), port), 22)
}
_ => {
completed.push(idx);
continue;
}
};
conn.read_buf.drain(..addr_len);
conn.dest_addr = Some(addr);
// Open smoltcp TCP socket to the destination
let socket = sockets.get_mut::<TcpSocket>(conn.smol_handle);
let smol_addr = match addr.ip() {
std::net::IpAddr::V4(v4) => {
let o = v4.octets();
IpAddress::Ipv4(Ipv4Address::new(o[0], o[1], o[2], o[3]))
}
std::net::IpAddr::V6(v6) => {
IpAddress::Ipv6(smoltcp::wire::Ipv6Address::from_bytes(&v6.octets()))
}
};
let local_port = 10000 + (rand::random::<u16>() % 50000);
if socket
.connect(iface.context(), (smol_addr, addr.port()), local_port)
.is_err()
{
let _ = conn
.tcp_stream
.try_write(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
completed.push(idx);
continue;
}
// Send SOCKS5 success reply
let _ = conn.tcp_stream.try_write(&[
0x05,
0x00,
0x00,
0x01,
127,
0,
0,
1,
(actual_port >> 8) as u8,
(actual_port & 0xff) as u8,
]);
conn.socks_done = true;
}
} else {
// Data relay between SOCKS5 client and smoltcp socket
let socket = sockets.get_mut::<TcpSocket>(conn.smol_handle);
// Client → smoltcp
let mut buf = [0u8; 4096];
match conn.tcp_stream.try_read(&mut buf) {
Ok(0) => {
socket.close();
completed.push(idx);
continue;
}
Ok(n) => {
if socket.can_send() {
let _ = socket.send_slice(&buf[..n]);
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(_) => {
socket.close();
completed.push(idx);
continue;
}
}
// smoltcp → Client
if socket.can_recv() {
match socket.recv(|data| (data.len(), data.to_vec())) {
Ok(data) if !data.is_empty() => {
if conn.tcp_stream.try_write(&data).is_err() {
socket.close();
completed.push(idx);
continue;
}
}
_ => {}
}
}
// Check if smoltcp socket closed
if !socket.is_open() && !socket.is_active() {
completed.push(idx);
}
}
}
// Remove completed connections (in reverse order)
completed.sort_unstable();
completed.dedup();
for idx in completed.into_iter().rev() {
let conn = connections.remove(idx);
sockets.remove(conn.smol_handle);
}
// Timer ticks for WireGuard keepalives
timer_counter += 1;
if timer_counter.is_multiple_of(500) {
device.tick_timers();
}
// Small sleep to avoid busy-spinning
tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_cidr_ipv4() {
let (cidr, ip) = parse_cidr_address("10.0.0.2/24").unwrap();
assert_eq!(cidr.prefix_len(), 24);
assert_eq!(ip, IpAddress::Ipv4(Ipv4Address::new(10, 0, 0, 2)));
}
#[test]
fn test_parse_cidr_no_prefix() {
let (cidr, _) = parse_cidr_address("10.0.0.2").unwrap();
assert_eq!(cidr.prefix_len(), 32);
}
#[test]
fn test_parse_cidr_multi_address() {
let (_, ip) = parse_cidr_address("10.0.0.2/24, fd00::2/128").unwrap();
assert_eq!(ip, IpAddress::Ipv4(Ipv4Address::new(10, 0, 0, 2)));
}
#[test]
fn test_parse_key_valid() {
let key = "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=";
assert!(parse_key(key).is_ok());
}
#[test]
fn test_parse_key_invalid() {
assert!(parse_key("not-valid").is_err());
}
}
+83
View File
@@ -32,6 +32,10 @@ struct StoredVpnConfig {
nonce: String, // Base64 encoded nonce
created_at: i64,
last_used: Option<i64>,
#[serde(default)]
sync_enabled: bool,
#[serde(default)]
last_sync: Option<u64>,
}
/// VPN storage manager with encryption
@@ -220,6 +224,8 @@ impl VpnStorage {
nonce,
created_at: config.created_at,
last_used: config.last_used,
sync_enabled: config.sync_enabled,
last_sync: config.last_sync,
};
// Update existing or add new
@@ -251,6 +257,8 @@ impl VpnStorage {
config_data,
created_at: stored.created_at,
last_used: stored.last_used,
sync_enabled: stored.sync_enabled,
last_sync: stored.last_sync,
})
}
@@ -269,6 +277,8 @@ impl VpnStorage {
config_data: String::new(), // Don't include config data in list
created_at: stored.created_at,
last_used: stored.last_used,
sync_enabled: stored.sync_enabled,
last_sync: stored.last_sync,
})
.collect(),
)
@@ -300,6 +310,68 @@ impl VpnStorage {
}
}
/// Create a VPN config manually from validated data
pub fn create_config_manual(
&self,
name: &str,
vpn_type: VpnType,
config_data: &str,
) -> Result<VpnConfig, VpnError> {
// Validate the config by parsing it
match vpn_type {
VpnType::WireGuard => {
super::parse_wireguard_config(config_data)?;
}
VpnType::OpenVPN => {
super::parse_openvpn_config(config_data)?;
}
}
let id = Uuid::new_v4().to_string();
let sync_enabled = crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync();
let config = VpnConfig {
id,
name: name.to_string(),
vpn_type,
config_data: config_data.to_string(),
created_at: Utc::now().timestamp(),
last_used: None,
sync_enabled,
last_sync: None,
};
self.save_config(&config)?;
Ok(config)
}
/// Update the name of an existing VPN config
pub fn update_config_name(&self, id: &str, new_name: &str) -> Result<VpnConfig, VpnError> {
let mut config = self.load_config(id)?;
config.name = new_name.to_string();
self.save_config(&config)?;
Ok(config)
}
/// Update sync fields on a VPN config
pub fn update_sync_fields(
&self,
id: &str,
sync_enabled: bool,
last_sync: Option<u64>,
) -> Result<(), VpnError> {
let mut storage = self.load_storage()?;
if let Some(config) = storage.configs.iter_mut().find(|c| c.id == id) {
config.sync_enabled = sync_enabled;
config.last_sync = last_sync;
self.save_storage(&storage)
} else {
Err(VpnError::NotFound(id.to_string()))
}
}
/// Import a VPN config from raw content
pub fn import_config(
&self,
@@ -325,6 +397,7 @@ impl VpnStorage {
let base = filename.trim_end_matches(".conf").trim_end_matches(".ovpn");
format!("{} ({})", base, vpn_type)
});
let sync_enabled = crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync();
let config = VpnConfig {
id,
@@ -333,6 +406,8 @@ impl VpnStorage {
config_data: content.to_string(),
created_at: Utc::now().timestamp(),
last_used: None,
sync_enabled,
last_sync: None,
};
self.save_config(&config)?;
@@ -375,6 +450,8 @@ mod tests {
config_data: "[Interface]\nPrivateKey = test\n[Peer]\nPublicKey = peer".to_string(),
created_at: 1234567890,
last_used: None,
sync_enabled: false,
last_sync: None,
};
storage.save_config(&config).unwrap();
@@ -397,6 +474,8 @@ mod tests {
config_data: "secret1".to_string(),
created_at: 1000,
last_used: None,
sync_enabled: false,
last_sync: None,
};
let config2 = VpnConfig {
@@ -406,6 +485,8 @@ mod tests {
config_data: "secret2".to_string(),
created_at: 2000,
last_used: Some(3000),
sync_enabled: false,
last_sync: None,
};
storage.save_config(&config1).unwrap();
@@ -430,6 +511,8 @@ mod tests {
config_data: "data".to_string(),
created_at: 1000,
last_used: None,
sync_enabled: false,
last_sync: None,
};
storage.save_config(&config).unwrap();
+245
View File
@@ -0,0 +1,245 @@
use crate::proxy_storage::is_process_running;
use crate::vpn_worker_storage::{
delete_vpn_worker_config, find_vpn_worker_by_vpn_id, generate_vpn_worker_id,
get_vpn_worker_config, list_vpn_worker_configs, save_vpn_worker_config, VpnWorkerConfig,
};
use std::process::Stdio;
pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
// Check if a VPN worker for this vpn_id already exists and is running
if let Some(existing) = find_vpn_worker_by_vpn_id(vpn_id) {
if let Some(pid) = existing.pid {
if is_process_running(pid) {
return Ok(existing);
}
}
// Worker config exists but process is dead, clean up
delete_vpn_worker_config(&existing.id);
}
// Load VPN config from storage to determine type
let vpn_config = {
let storage = crate::vpn::VPN_STORAGE
.lock()
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
storage
.load_config(vpn_id)
.map_err(|e| format!("Failed to load VPN config: {e}"))?
};
let vpn_type_str = match vpn_config.vpn_type {
crate::vpn::VpnType::WireGuard => "wireguard",
crate::vpn::VpnType::OpenVPN => "openvpn",
};
// Write decrypted config to a temp file
let config_file_path = std::env::temp_dir()
.join(format!("donut_vpn_{}.conf", vpn_id))
.to_string_lossy()
.to_string();
std::fs::write(&config_file_path, &vpn_config.config_data)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&config_file_path, std::fs::Permissions::from_mode(0o600));
}
let id = generate_vpn_worker_id();
// Find an available port
let local_port = {
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
listener.local_addr()?.port()
};
let config = VpnWorkerConfig::new(
id.clone(),
vpn_id.to_string(),
vpn_type_str.to_string(),
config_file_path,
);
save_vpn_worker_config(&config)?;
// Spawn detached VPN worker process
let exe = std::env::current_exe()?;
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
use std::process::Command as StdCommand;
let mut cmd = StdCommand::new(&exe);
cmd.arg("vpn-worker");
cmd.arg("start");
cmd.arg("--id");
cmd.arg(&id);
cmd.arg("--port");
cmd.arg(local_port.to_string());
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
let log_path = std::env::temp_dir().join(format!("donut-vpn-{}.log", id));
if let Ok(file) = std::fs::File::create(&log_path) {
log::info!("VPN worker stderr will be logged to: {:?}", log_path);
cmd.stderr(Stdio::from(file));
} else {
cmd.stderr(Stdio::null());
}
unsafe {
cmd.pre_exec(|| {
libc::setsid();
if libc::setpriority(libc::PRIO_PROCESS, 0, -10) != 0 {
let _ = libc::setpriority(libc::PRIO_PROCESS, 0, -5);
}
Ok(())
});
}
let child = cmd.spawn()?;
let pid = child.id();
let mut config_with_pid = config.clone();
config_with_pid.pid = Some(pid);
config_with_pid.local_port = Some(local_port);
save_vpn_worker_config(&config_with_pid)?;
drop(child);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
use std::process::Command as StdCommand;
let mut cmd = StdCommand::new(&exe);
cmd.arg("vpn-worker");
cmd.arg("start");
cmd.arg("--id");
cmd.arg(&id);
cmd.arg("--port");
cmd.arg(local_port.to_string());
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
let log_path = std::env::temp_dir().join(format!("donut-vpn-{}.log", id));
if let Ok(file) = std::fs::File::create(&log_path) {
log::info!("VPN worker stderr will be logged to: {:?}", log_path);
cmd.stderr(Stdio::from(file));
} else {
cmd.stderr(Stdio::null());
}
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
let child = cmd.spawn()?;
let pid = child.id();
let mut config_with_pid = config.clone();
config_with_pid.pid = Some(pid);
config_with_pid.local_port = Some(local_port);
save_vpn_worker_config(&config_with_pid)?;
drop(child);
}
// Wait for the worker to update config with local_url
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let mut attempts = 0;
let max_attempts = 100; // 10 seconds max
loop {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
if let Some(updated_config) = get_vpn_worker_config(&id) {
if let Some(ref local_url) = updated_config.local_url {
if !local_url.is_empty() {
if let Some(port) = updated_config.local_port {
if let Ok(Ok(_)) = tokio::time::timeout(
tokio::time::Duration::from_millis(100),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await
{
return Ok(updated_config);
}
}
}
}
}
attempts += 1;
if attempts >= max_attempts {
if let Some(config) = get_vpn_worker_config(&id) {
let process_running = config.pid.map(is_process_running).unwrap_or(false);
// Clean up on failure
delete_vpn_worker_config(&id);
return Err(
format!(
"VPN worker failed to start in time. pid={:?}, process_running={}, local_url={:?}",
config.pid, process_running, config.local_url
)
.into(),
);
}
delete_vpn_worker_config(&id);
return Err("VPN worker config not found after spawn".into());
}
}
}
pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
let config = get_vpn_worker_config(id);
if let Some(config) = config {
if let Some(pid) = config.pid {
#[cfg(unix)]
{
use std::process::Command;
let _ = Command::new("kill")
.arg("-TERM")
.arg(pid.to_string())
.output();
}
#[cfg(windows)]
{
use std::process::Command;
let _ = Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.output();
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
// Clean up temp config file
let _ = std::fs::remove_file(&config.config_file_path);
delete_vpn_worker_config(id);
return Ok(true);
}
Ok(false)
}
pub async fn stop_vpn_worker_by_vpn_id(vpn_id: &str) -> Result<bool, Box<dyn std::error::Error>> {
if let Some(config) = find_vpn_worker_by_vpn_id(vpn_id) {
return stop_vpn_worker(&config.id).await;
}
Ok(false)
}
pub async fn stop_all_vpn_workers() -> Result<(), Box<dyn std::error::Error>> {
let configs = list_vpn_worker_configs();
for config in configs {
let _ = stop_vpn_worker(&config.id).await;
}
Ok(())
}
+107
View File
@@ -0,0 +1,107 @@
use crate::proxy_storage::get_storage_dir;
use serde::{Deserialize, Serialize};
use std::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VpnWorkerConfig {
pub id: String,
pub vpn_id: String,
pub vpn_type: String,
pub config_file_path: String,
pub local_port: Option<u16>,
pub local_url: Option<String>,
pub pid: Option<u32>,
}
impl VpnWorkerConfig {
pub fn new(id: String, vpn_id: String, vpn_type: String, config_file_path: String) -> Self {
Self {
id,
vpn_id,
vpn_type,
config_file_path,
local_port: None,
local_url: None,
pid: None,
}
}
}
pub fn save_vpn_worker_config(config: &VpnWorkerConfig) -> Result<(), Box<dyn std::error::Error>> {
let storage_dir = get_storage_dir();
fs::create_dir_all(&storage_dir)?;
let file_path = storage_dir.join(format!("vpn_worker_{}.json", config.id));
let content = serde_json::to_string_pretty(config)?;
fs::write(&file_path, content)?;
Ok(())
}
pub fn get_vpn_worker_config(id: &str) -> Option<VpnWorkerConfig> {
let storage_dir = get_storage_dir();
let file_path = storage_dir.join(format!("vpn_worker_{}.json", id));
if !file_path.exists() {
return None;
}
match fs::read_to_string(&file_path) {
Ok(content) => serde_json::from_str(&content).ok(),
Err(_) => None,
}
}
pub fn delete_vpn_worker_config(id: &str) -> bool {
let storage_dir = get_storage_dir();
let file_path = storage_dir.join(format!("vpn_worker_{}.json", id));
if !file_path.exists() {
return false;
}
fs::remove_file(&file_path).is_ok()
}
pub fn list_vpn_worker_configs() -> Vec<VpnWorkerConfig> {
let storage_dir = get_storage_dir();
if !storage_dir.exists() {
return Vec::new();
}
let mut configs = Vec::new();
if let Ok(entries) = fs::read_dir(&storage_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with("vpn_worker_") && name.ends_with(".json") {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(config) = serde_json::from_str::<VpnWorkerConfig>(&content) {
configs.push(config);
}
}
}
}
}
}
configs
}
pub fn find_vpn_worker_by_vpn_id(vpn_id: &str) -> Option<VpnWorkerConfig> {
list_vpn_worker_configs()
.into_iter()
.find(|c| c.vpn_id == vpn_id)
}
pub fn generate_vpn_worker_id() -> String {
format!(
"vpnw_{}_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
rand::random::<u32>()
)
}
+18 -2
View File
@@ -231,7 +231,15 @@ impl WayfernManager {
config: &WayfernConfig,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let executable_path = if let Some(path) = &config.executable_path {
PathBuf::from(path)
let p = PathBuf::from(path);
if p.exists() {
p
} else {
log::warn!("Stored Wayfern executable path does not exist: {path}, falling back to dynamic resolution");
BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?
}
} else {
BrowserRunner::instance()
.get_browser_executable_path(profile)
@@ -400,7 +408,15 @@ impl WayfernManager {
proxy_url: Option<&str>,
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
let executable_path = if let Some(path) = &config.executable_path {
PathBuf::from(path)
let p = PathBuf::from(path);
if p.exists() {
p
} else {
log::warn!("Stored Wayfern executable path does not exist: {path}, falling back to dynamic resolution");
BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?
}
} else {
BrowserRunner::instance()
.get_browser_executable_path(profile)
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.14.2",
"version": "0.14.6",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
@@ -32,7 +32,7 @@
"frameworks": [],
"minimumSystemVersion": "10.13",
"exceptionDomain": "",
"signingIdentity": "-",
"signingIdentity": null,
"providerShortName": null,
"entitlements": "entitlements.plist",
"files": {
+6
View File
@@ -188,6 +188,8 @@ fn test_vpn_storage_save_and_load() {
config_data: "[Interface]\nPrivateKey=key\n[Peer]\nPublicKey=peer".to_string(),
created_at: 1234567890,
last_used: None,
sync_enabled: false,
last_sync: None,
};
let save_result = storage.save_config(&config);
@@ -230,6 +232,8 @@ fn test_vpn_storage_list() {
config_data: "secret data".to_string(),
created_at: 1000 * i as i64,
last_used: None,
sync_enabled: false,
last_sync: None,
};
storage.save_config(&config).unwrap();
}
@@ -256,6 +260,8 @@ fn test_vpn_storage_delete() {
config_data: "data".to_string(),
created_at: 1000,
last_used: None,
sync_enabled: false,
last_sync: None,
};
storage.save_config(&config).unwrap();
+82 -13
View File
@@ -3,7 +3,7 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
@@ -23,6 +23,7 @@ import { ProfileSyncDialog } from "@/components/profile-sync-dialog";
import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
import { SettingsDialog } from "@/components/settings-dialog";
import { SyncAllDialog } from "@/components/sync-all-dialog";
import { SyncConfigDialog } from "@/components/sync-config-dialog";
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
@@ -35,8 +36,14 @@ import { useProfileEvents } from "@/hooks/use-profile-events";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { showErrorToast, showSuccessToast, showToast } from "@/lib/toast-utils";
import {
dismissToast,
showErrorToast,
showSuccessToast,
showToast,
} from "@/lib/toast-utils";
import type { BrowserProfile, CamoufoxConfig, WayfernConfig } from "@/types";
type BrowserTypeString =
@@ -77,6 +84,8 @@ export default function Home() {
error: proxiesError,
} = useProxyEvents();
const { vpnConfigs } = useVpnEvents();
// Wayfern terms and commercial trial hooks
const {
termsAccepted,
@@ -92,7 +101,9 @@ export default function Home() {
// Cloud auth for cross-OS unlock
const { user: cloudUser } = useCloudAuth();
const crossOsUnlocked =
cloudUser?.plan !== "free" && cloudUser?.subscriptionStatus === "active";
cloudUser?.plan !== "free" &&
(cloudUser?.subscriptionStatus === "active" ||
cloudUser?.planPeriod === "lifetime");
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
@@ -133,12 +144,15 @@ export default function Home() {
useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [syncConfigDialogOpen, setSyncConfigDialogOpen] = useState(false);
const [syncAllDialogOpen, setSyncAllDialogOpen] = useState(false);
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
const [currentProfileForSync, setCurrentProfileForSync] =
useState<BrowserProfile | null>(null);
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
usePermissions();
const userInitiatedSyncIds = useRef<Set<string>>(new Set());
const handleSelectGroup = useCallback((groupId: string) => {
setSelectedGroupId(groupId);
setSelectedProfiles([]);
@@ -446,6 +460,7 @@ export default function Home() {
version: string;
releaseType: string;
proxyId?: string;
vpnId?: string;
camoufoxConfig?: CamoufoxConfig;
wayfernConfig?: WayfernConfig;
groupId?: string;
@@ -457,6 +472,7 @@ export default function Home() {
version: profileData.version,
releaseType: profileData.releaseType,
proxyId: profileData.proxyId,
vpnId: profileData.vpnId,
camoufoxConfig: profileData.camoufoxConfig,
wayfernConfig: profileData.wayfernConfig,
groupId:
@@ -674,18 +690,19 @@ export default function Home() {
const handleToggleProfileSync = useCallback(
async (profile: BrowserProfile) => {
try {
const enabling = !profile.sync_enabled;
await invoke("set_profile_sync_enabled", {
profileId: profile.id,
enabled: !profile.sync_enabled,
enabled: enabling,
});
if (enabling) {
userInitiatedSyncIds.current.add(profile.id);
}
showSuccessToast(enabling ? "Sync enabled" : "Sync disabled", {
description: enabling
? "Profile sync has been enabled"
: "Profile sync has been disabled",
});
showSuccessToast(
profile.sync_enabled ? "Sync disabled" : "Sync enabled",
{
description: profile.sync_enabled
? "Profile sync has been disabled"
: "Profile sync has been enabled",
},
);
} catch (error) {
console.error("Failed to toggle sync:", error);
showErrorToast("Failed to update sync settings");
@@ -694,6 +711,47 @@ export default function Home() {
[],
);
useEffect(() => {
let unlisten: (() => void) | undefined;
(async () => {
try {
unlisten = await listen<{ profile_id: string; status: string }>(
"profile-sync-status",
(event) => {
const { profile_id, status } = event.payload;
if (!userInitiatedSyncIds.current.has(profile_id)) return;
const toastId = `sync-${profile_id}`;
const profile = profiles.find((p) => p.id === profile_id);
const name = profile?.name ?? "Unknown";
if (status === "syncing") {
showToast({
type: "loading",
title: `Syncing profile '${name}'...`,
id: toastId,
duration: 30000,
});
} else if (status === "synced") {
dismissToast(toastId);
showSuccessToast(`Profile '${name}' synced successfully`);
userInitiatedSyncIds.current.delete(profile_id);
} else if (status === "error") {
dismissToast(toastId);
showErrorToast(`Failed to sync profile '${name}'`);
userInitiatedSyncIds.current.delete(profile_id);
}
},
);
} catch (error) {
console.error("Failed to listen for sync status events:", error);
}
})();
return () => {
if (unlisten) unlisten();
};
}, [profiles]);
useEffect(() => {
// Check for startup default browser prompt
void checkStartupPrompt();
@@ -1033,6 +1091,7 @@ export default function Home() {
onAssignmentComplete={handleProxyAssignmentComplete}
profiles={profiles}
storedProxies={storedProxies}
vpnConfigs={vpnConfigs}
/>
<CookieCopyDialog
@@ -1061,7 +1120,17 @@ export default function Home() {
<SyncConfigDialog
isOpen={syncConfigDialogOpen}
onClose={() => setSyncConfigDialogOpen(false)}
onClose={(loginOccurred) => {
setSyncConfigDialogOpen(false);
if (loginOccurred) {
setSyncAllDialogOpen(true);
}
}}
/>
<SyncAllDialog
isOpen={syncAllDialogOpen}
onClose={() => setSyncAllDialogOpen(false)}
/>
<ProfileSyncDialog
+38 -174
View File
@@ -1,69 +1,24 @@
"use client";
import { FaDownload, FaExternalLinkAlt, FaTimes } from "react-icons/fa";
import { LuCheckCheck, LuCog, LuRefreshCw } from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
import { FaExternalLinkAlt, FaTimes } from "react-icons/fa";
import { LuCheckCheck } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import type { AppUpdateInfo, AppUpdateProgress } from "@/types";
import type { AppUpdateInfo } from "@/types";
import { RippleButton } from "./ui/ripple";
interface AppUpdateToastProps {
updateInfo: AppUpdateInfo;
onUpdate: (updateInfo: AppUpdateInfo) => Promise<void>;
onRestart: () => Promise<void>;
onDismiss: () => void;
isUpdating?: boolean;
updateProgress?: AppUpdateProgress | null;
updateReady?: boolean;
}
function getStageIcon(stage?: string, isUpdating?: boolean) {
if (!isUpdating) {
return <FaDownload className="flex-shrink-0 w-5 h-5" />;
}
switch (stage) {
case "downloading":
return <FaDownload className="flex-shrink-0 w-5 h-5" />;
case "extracting":
return <LuRefreshCw className="flex-shrink-0 w-5 h-5 animate-spin" />;
case "installing":
return <LuCog className="flex-shrink-0 w-5 h-5 animate-spin" />;
case "completed":
return <LuCheckCheck className="flex-shrink-0 w-5 h-5" />;
default:
return <LuRefreshCw className="flex-shrink-0 w-5 h-5 animate-spin" />;
}
}
function getStageDisplayName(stage?: string) {
switch (stage) {
case "downloading":
return "Downloading";
case "extracting":
return "Extracting";
case "installing":
return "Installing";
case "completed":
return "Completed";
default:
return "Updating";
}
}
export function AppUpdateToast({
updateInfo,
onUpdate,
onRestart,
onDismiss,
isUpdating = false,
updateProgress,
updateReady = false,
}: AppUpdateToastProps) {
const handleUpdateClick = async () => {
await onUpdate(updateInfo);
};
const handleRestartClick = async () => {
await onRestart();
};
@@ -77,115 +32,37 @@ export function AppUpdateToast({
}
};
const showDownloadProgress =
isUpdating &&
updateProgress?.stage === "downloading" &&
updateProgress.percentage !== undefined;
const showOtherStageProgress =
isUpdating &&
updateProgress &&
(updateProgress.stage === "extracting" ||
updateProgress.stage === "installing" ||
updateProgress.stage === "completed");
return (
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="mr-3 mt-0.5">
{updateReady ? (
<LuCheckCheck className="flex-shrink-0 w-5 h-5 text-green-500" />
) : (
getStageIcon(updateProgress?.stage, isUpdating)
)}
<LuCheckCheck className="flex-shrink-0 w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex gap-2 justify-between items-start">
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-center">
<span className="text-sm font-semibold text-foreground">
{updateReady
? "The update is ready, restart app"
: isUpdating
? `${getStageDisplayName(updateProgress?.stage)} Donut Browser Update`
: "Donut Browser Update Available"}
</span>
{!updateReady && (
<Badge
variant={updateInfo.is_nightly ? "secondary" : "default"}
className="text-xs"
>
{updateInfo.is_nightly ? "Nightly" : "Stable"}
</Badge>
)}
<span className="text-sm font-semibold text-foreground">
{updateReady
? "Update ready, restart to apply"
: "Manual download required"}
</span>
<div className="text-xs text-muted-foreground">
{updateInfo.current_version} {updateInfo.new_version}
</div>
{!updateReady && (
<div className="text-xs text-muted-foreground">
{isUpdating ? (
updateProgress?.message || "Updating..."
) : (
<>
Update from {updateInfo.current_version} to{" "}
<span className="font-medium">
{updateInfo.new_version}
</span>
{updateInfo.manual_update_required && (
<span className="block mt-1 text-muted-foreground/80">
Manual download required on Linux
</span>
)}
</>
)}
</div>
)}
</div>
{!isUpdating && !updateReady && (
<Button
variant="ghost"
size="sm"
onClick={onDismiss}
className="p-0 w-6 h-6 shrink-0"
>
<FaTimes className="w-3 h-3" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={onDismiss}
className="p-0 w-6 h-6 shrink-0"
>
<FaTimes className="w-3 h-3" />
</Button>
</div>
{!updateReady && showDownloadProgress && updateProgress && (
<div className="mt-2 space-y-1">
<div className="flex justify-between items-center">
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
{updateProgress.percentage?.toFixed(1)}%
{updateProgress.speed && `${updateProgress.speed} MB/s`}
{updateProgress.eta && `${updateProgress.eta} remaining`}
</p>
</div>
<div className="w-full bg-muted rounded-full h-1.5">
<div
className="bg-primary h-1.5 rounded-full transition-all duration-300"
style={{ width: `${updateProgress.percentage}%` }}
/>
</div>
</div>
)}
{!updateReady && showOtherStageProgress && (
<div className="mt-2 space-y-1">
<div className="w-full bg-muted rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all duration-500 ${
updateProgress.stage === "completed"
? "bg-green-500 w-full"
: "bg-primary w-full animate-pulse"
}`}
/>
</div>
</div>
)}
{updateReady ? (
<div className="flex gap-2 items-center mt-3">
<div className="flex gap-2 items-center mt-3">
{updateReady ? (
<RippleButton
onClick={() => void handleRestartClick()}
size="sm"
@@ -194,40 +71,27 @@ export function AppUpdateToast({
<LuCheckCheck className="w-3 h-3" />
Restart Now
</RippleButton>
</div>
) : (
!isUpdating && (
<div className="flex gap-2 items-center mt-3">
{updateInfo.manual_update_required ? (
<RippleButton
onClick={handleViewRelease}
size="sm"
className="flex gap-2 items-center text-xs"
>
<FaExternalLinkAlt className="w-3 h-3" />
View Release
</RippleButton>
) : (
<RippleButton
onClick={() => void handleUpdateClick()}
size="sm"
className="flex gap-2 items-center text-xs"
>
<FaDownload className="w-3 h-3" />
Download Update
</RippleButton>
)}
) : (
updateInfo.manual_update_required && (
<RippleButton
variant="outline"
onClick={onDismiss}
onClick={handleViewRelease}
size="sm"
className="text-xs"
className="flex gap-2 items-center text-xs"
>
Later
<FaExternalLinkAlt className="w-3 h-3" />
View Release
</RippleButton>
</div>
)
)}
)
)}
<RippleButton
variant="outline"
onClick={onDismiss}
size="sm"
className="text-xs"
>
Later
</RippleButton>
</div>
</div>
</div>
);
+92 -32
View File
@@ -20,7 +20,9 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
@@ -29,6 +31,7 @@ import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { getBrowserIcon } from "@/lib/browser-utils";
import type {
BrowserReleaseTypes,
@@ -66,6 +69,7 @@ interface CreateProfileDialogProps {
version: string;
releaseType: string;
proxyId?: string;
vpnId?: string;
camoufoxConfig?: CamoufoxConfig;
wayfernConfig?: WayfernConfig;
groupId?: string;
@@ -155,6 +159,7 @@ export function CreateProfileDialog({
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
const { storedProxies } = useProxyEvents();
const { vpnConfigs } = useVpnEvents();
const [showProxyForm, setShowProxyForm] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>();
@@ -347,6 +352,11 @@ export function CreateProfileDialog({
if (!profileName.trim()) return;
setIsCreating(true);
const isVpnSelection = selectedProxyId?.startsWith("vpn-") ?? false;
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
const resolvedVpnId =
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
try {
if (activeTab === "anti-detect") {
// Anti-detect browser - check if Wayfern or Camoufox is selected
@@ -365,7 +375,8 @@ export function CreateProfileDialog({
browserStr: "wayfern" as BrowserTypeString,
version: bestWayfernVersion.version,
releaseType: bestWayfernVersion.releaseType,
proxyId: selectedProxyId,
proxyId: resolvedProxyId,
vpnId: resolvedVpnId,
wayfernConfig: finalWayfernConfig,
groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined,
@@ -387,7 +398,8 @@ export function CreateProfileDialog({
browserStr: "camoufox" as BrowserTypeString,
version: bestCamoufoxVersion.version,
releaseType: bestCamoufoxVersion.releaseType,
proxyId: selectedProxyId,
proxyId: resolvedProxyId,
vpnId: resolvedVpnId,
camoufoxConfig: finalCamoufoxConfig,
groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined,
@@ -543,9 +555,7 @@ export function CreateProfileDialog({
})()}
</div>
<div className="text-left">
<div className="font-medium">
Chromium (Wayfern)
</div>
<div className="font-medium">Wayfern</div>
<div className="text-sm text-muted-foreground">
Anti-Detect Browser
</div>
@@ -568,9 +578,7 @@ export function CreateProfileDialog({
})()}
</div>
<div className="text-left">
<div className="font-medium">
Firefox (Camoufox)
</div>
<div className="font-medium">Camoufox</div>
<div className="text-sm text-muted-foreground">
Anti-Detect Browser
</div>
@@ -946,10 +954,10 @@ export function CreateProfileDialog({
</div>
)}
{/* Proxy Selection - Always visible */}
{/* Proxy / VPN Selection - Always visible */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label>Proxy</Label>
<Label>Proxy / VPN</Label>
<RippleButton
size="sm"
variant="outline"
@@ -959,7 +967,7 @@ export function CreateProfileDialog({
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
</RippleButton>
</div>
{storedProxies.length > 0 ? (
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
<Select
value={selectedProxyId || "none"}
onValueChange={(value) =>
@@ -969,21 +977,47 @@ export function CreateProfileDialog({
}
>
<SelectTrigger>
<SelectValue placeholder="No proxy" />
<SelectValue placeholder="No proxy / VPN" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No proxy</SelectItem>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
</SelectItem>
))}
<SelectItem value="none">
No proxy / VPN
</SelectItem>
{storedProxies.length > 0 && (
<SelectGroup>
<SelectLabel>Proxies</SelectLabel>
{storedProxies.map((proxy) => (
<SelectItem
key={proxy.id}
value={proxy.id}
>
{proxy.name}
</SelectItem>
))}
</SelectGroup>
)}
{vpnConfigs.length > 0 && (
<SelectGroup>
<SelectLabel>VPNs</SelectLabel>
{vpnConfigs.map((vpn) => (
<SelectItem
key={vpn.id}
value={`vpn-${vpn.id}`}
>
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}{" "}
{vpn.name}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
) : (
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
No proxies available. Add one to route this
profile's traffic.
No proxies or VPNs available. Add one to route
this profile's traffic.
</div>
)}
</div>
@@ -1107,10 +1141,10 @@ export function CreateProfileDialog({
)}
</div>
{/* Proxy Selection - Always visible */}
{/* Proxy / VPN Selection - Always visible */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label>Proxy</Label>
<Label>Proxy / VPN</Label>
<RippleButton
size="sm"
variant="outline"
@@ -1120,7 +1154,7 @@ export function CreateProfileDialog({
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
</RippleButton>
</div>
{storedProxies.length > 0 ? (
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
<Select
value={selectedProxyId || "none"}
onValueChange={(value) =>
@@ -1130,21 +1164,47 @@ export function CreateProfileDialog({
}
>
<SelectTrigger>
<SelectValue placeholder="No proxy" />
<SelectValue placeholder="No proxy / VPN" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No proxy</SelectItem>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
</SelectItem>
))}
<SelectItem value="none">
No proxy / VPN
</SelectItem>
{storedProxies.length > 0 && (
<SelectGroup>
<SelectLabel>Proxies</SelectLabel>
{storedProxies.map((proxy) => (
<SelectItem
key={proxy.id}
value={proxy.id}
>
{proxy.name}
</SelectItem>
))}
</SelectGroup>
)}
{vpnConfigs.length > 0 && (
<SelectGroup>
<SelectLabel>VPNs</SelectLabel>
{vpnConfigs.map((vpn) => (
<SelectItem
key={vpn.id}
value={`vpn-${vpn.id}`}
>
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}{" "}
{vpn.name}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
) : (
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
No proxies available. Add one to route this
profile's traffic.
No proxies or VPNs available. Add one to route
this profile's traffic.
</div>
)}
</div>
File diff suppressed because one or more lines are too long
+1
View File
@@ -8,6 +8,7 @@ export const ZenBrowser = (props: React.SVGProps<SVGSVGElement>) => (
viewBox="0 0 24 24"
{...props}
>
<title>Zen Browser</title>
<path
d="M12 8.15c-2.12 0-3.85 1.72-3.85 3.85s1.72 3.85 3.85 3.85 3.85-1.72 3.85-3.85S14.13 8.15 12 8.15m0 6.92c-1.7 0-3.08-1.38-3.08-3.08S10.3 8.91 12 8.91s3.08 1.38 3.08 3.08-1.38 3.08-3.08 3.08"
className="b"
+301 -46
View File
@@ -13,6 +13,7 @@ import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import type { Dispatch, SetStateAction } from "react";
import * as React from "react";
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { IoEllipsisHorizontal } from "react-icons/io5";
import {
@@ -64,10 +65,13 @@ import {
import { useBrowserState } from "@/hooks/use-browser-state";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useTableSorting } from "@/hooks/use-table-sorting";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import {
getBrowserDisplayName,
getBrowserIcon,
getCurrentOS,
getOSDisplayName,
isCrossOsProfile,
} from "@/lib/browser-utils";
import { formatRelativeTime } from "@/lib/flag-utils";
import { trimName } from "@/lib/name-utils";
@@ -78,6 +82,7 @@ import type {
ProxyCheckResult,
StoredProxy,
TrafficSnapshot,
VpnConfig,
} from "@/types";
import { BandwidthMiniChart } from "./bandwidth-mini-chart";
import {
@@ -134,6 +139,14 @@ type TableMeta = {
checkingProfileId: string | null;
proxyCheckResults: Record<string, ProxyCheckResult>;
// VPN selector state
vpnConfigs: VpnConfig[];
vpnOverrides: Record<string, string | null>;
handleVpnSelection: (
profileId: string,
vpnId: string | null,
) => void | Promise<void>;
// Selection helpers
isProfileSelected: (id: string) => boolean;
handleToggleAll: (checked: boolean) => void;
@@ -184,6 +197,53 @@ type TableMeta = {
) => Promise<void>;
};
type SyncStatusDot = { color: string; tooltip: string; animate: boolean };
function getProfileSyncStatusDot(
profile: BrowserProfile,
liveStatus:
| "syncing"
| "waiting"
| "synced"
| "error"
| "disabled"
| undefined,
): SyncStatusDot | null {
const status = liveStatus ?? (profile.sync_enabled ? "synced" : "disabled");
switch (status) {
case "syncing":
return { color: "bg-yellow-500", tooltip: "Syncing...", animate: true };
case "waiting":
return {
color: "bg-yellow-500",
tooltip: "Waiting to sync",
animate: false,
};
case "synced":
return {
color: "bg-green-500",
tooltip: profile.last_sync
? `Synced ${new Date(profile.last_sync * 1000).toLocaleString()}`
: "Synced",
animate: false,
};
case "error":
return { color: "bg-red-500", tooltip: "Sync error", animate: false };
case "disabled":
if (profile.last_sync) {
return {
color: "bg-gray-400",
tooltip: `Sync disabled, last sync ${formatRelativeTime(profile.last_sync)}`,
animate: false,
};
}
return null;
default:
return null;
}
}
const TagsCell = React.memo<{
profile: BrowserProfile;
isDisabled: boolean;
@@ -798,10 +858,14 @@ export function ProfilesDataTable({
);
const { storedProxies } = useProxyEvents();
const { vpnConfigs } = useVpnEvents();
const [proxyOverrides, setProxyOverrides] = React.useState<
Record<string, string | null>
>({});
const [vpnOverrides, setVpnOverrides] = React.useState<
Record<string, string | null>
>({});
const [showCheckboxes, setShowCheckboxes] = React.useState(false);
const [tagsOverrides, setTagsOverrides] = React.useState<
Record<string, string[]>
@@ -900,7 +964,7 @@ export function ProfilesDataTable({
proxyId,
});
setProxyOverrides((prev) => ({ ...prev, [profileId]: proxyId }));
// Notify other parts of the app so usage counts and lists refresh
setVpnOverrides((prev) => ({ ...prev, [profileId]: null }));
await emit("profile-updated");
} catch (error) {
console.error("Failed to update proxy settings:", error);
@@ -911,6 +975,25 @@ export function ProfilesDataTable({
[],
);
const handleVpnSelection = React.useCallback(
async (profileId: string, vpnId: string | null) => {
try {
await invoke("update_profile_vpn", {
profileId,
vpnId,
});
setVpnOverrides((prev) => ({ ...prev, [profileId]: vpnId }));
setProxyOverrides((prev) => ({ ...prev, [profileId]: null }));
await emit("profile-updated");
} catch (error) {
console.error("Failed to update VPN settings:", error);
} finally {
setOpenProxySelectorFor(null);
}
},
[],
);
const handleCreateCountryProxy = React.useCallback(
async (profileId: string, country: LocationItem) => {
try {
@@ -1344,6 +1427,11 @@ export function ProfilesDataTable({
checkingProfileId,
proxyCheckResults,
// VPN selector state
vpnConfigs,
vpnOverrides,
handleVpnSelection,
// Selection helpers
isProfileSelected: (id: string) => selectedProfiles.includes(id),
handleToggleAll,
@@ -1412,6 +1500,9 @@ export function ProfilesDataTable({
handleProxySelection,
checkingProfileId,
proxyCheckResults,
vpnConfigs,
vpnOverrides,
handleVpnSelection,
handleToggleAll,
handleCheckboxChange,
handleIconClick,
@@ -1464,6 +1555,7 @@ export function ProfilesDataTable({
const profile = row.original;
const browser = profile.browser;
const IconComponent = getBrowserIcon(browser);
const isCrossOs = isCrossOsProfile(profile);
const isSelected = meta.isProfileSelected(profile.id);
const isRunning =
@@ -1474,6 +1566,74 @@ export function ProfilesDataTable({
const isDisabled =
isRunning || isLaunching || isStopping || isBrowserUpdating;
// Cross-OS profiles: show OS icon when checkboxes aren't visible, show checkbox when they are
if (isCrossOs && !meta.showCheckboxes && !isSelected) {
const osName = profile.host_os
? getOSDisplayName(profile.host_os)
: "another OS";
const OsIcon =
profile.host_os === "macos"
? FaApple
: profile.host_os === "windows"
? FaWindows
: FaLinux;
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex justify-center items-center w-4 h-4">
<button
type="button"
className="flex justify-center items-center p-0 border-none cursor-pointer"
onClick={() => meta.handleIconClick(profile.id)}
aria-label="Select profile"
>
<span className="w-4 h-4 group">
<OsIcon className="w-4 h-4 text-muted-foreground group-hover:hidden" />
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
</span>
</button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
This profile was created on {osName} and is not supported on
this system
</p>
</TooltipContent>
</Tooltip>
);
}
// Cross-OS profiles with checkboxes visible: show checkbox (selectable for bulk delete)
if (isCrossOs && (meta.showCheckboxes || isSelected)) {
const osName = profile.host_os
? getOSDisplayName(profile.host_os)
: "another OS";
return (
<NonHoverableTooltip
content={
<p>
This profile was created on {osName} and is not supported on
this system
</p>
}
sideOffset={4}
horizontalOffset={8}
>
<span className="flex justify-center items-center w-4 h-4">
<Checkbox
checked={isSelected}
onCheckedChange={(value) =>
meta.handleCheckboxChange(profile.id, !!value)
}
aria-label="Select row"
className="w-4 h-4"
/>
</span>
</NonHoverableTooltip>
);
}
if (isDisabled) {
const tooltipMessage = isRunning
? "Can't modify running profile"
@@ -1718,13 +1878,18 @@ export function ProfilesDataTable({
</Tooltip>
);
const isCrossOs = isCrossOsProfile(profile);
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isBrowserUpdating = meta.isUpdating(profile.browser);
const isDisabled =
isRunning || isLaunching || isStopping || isBrowserUpdating;
isRunning ||
isLaunching ||
isStopping ||
isBrowserUpdating ||
isCrossOs;
return (
<button
@@ -1762,13 +1927,18 @@ export function ProfilesDataTable({
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isBrowserUpdating = meta.isUpdating(profile.browser);
const isDisabled =
isRunning || isLaunching || isStopping || isBrowserUpdating;
isRunning ||
isLaunching ||
isStopping ||
isBrowserUpdating ||
isCrossOs;
return (
<TagsCell
@@ -1790,13 +1960,18 @@ export function ProfilesDataTable({
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isBrowserUpdating = meta.isUpdating(profile.browser);
const isDisabled =
isRunning || isLaunching || isStopping || isBrowserUpdating;
isRunning ||
isLaunching ||
isStopping ||
isBrowserUpdating ||
isCrossOs;
return (
<NoteCell
@@ -1812,40 +1987,61 @@ export function ProfilesDataTable({
},
{
id: "proxy",
header: "Proxy",
header: "Proxy / VPN",
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isBrowserUpdating = meta.isUpdating(profile.browser);
const isDisabled =
isRunning || isLaunching || isStopping || isBrowserUpdating;
isRunning ||
isLaunching ||
isStopping ||
isBrowserUpdating ||
isCrossOs;
const hasOverride = Object.hasOwn(meta.proxyOverrides, profile.id);
const effectiveProxyId = hasOverride
const hasProxyOverride = Object.hasOwn(
meta.proxyOverrides,
profile.id,
);
const effectiveProxyId = hasProxyOverride
? meta.proxyOverrides[profile.id]
: (profile.proxy_id ?? null);
const effectiveProxy = effectiveProxyId
? (meta.storedProxies.find((p) => p.id === effectiveProxyId) ??
null)
: null;
const displayName = effectiveProxy
? effectiveProxy.name
: "Not Selected";
const profileHasProxy = Boolean(effectiveProxy);
const tooltipText =
profileHasProxy && effectiveProxy ? effectiveProxy.name : null;
const hasVpnOverride = Object.hasOwn(meta.vpnOverrides, profile.id);
const effectiveVpnId = hasVpnOverride
? meta.vpnOverrides[profile.id]
: (profile.vpn_id ?? null);
const effectiveVpn = effectiveVpnId
? (meta.vpnConfigs.find((v) => v.id === effectiveVpnId) ?? null)
: null;
const hasAssignment = Boolean(effectiveProxy || effectiveVpn);
const displayName = effectiveVpn
? effectiveVpn.name
: effectiveProxy
? effectiveProxy.name
: "Not Selected";
const vpnBadge = effectiveVpn
? effectiveVpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"
: null;
const tooltipText = hasAssignment ? displayName : null;
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
const selectedId = effectiveVpnId ?? effectiveProxyId ?? null;
// When profile is running, show bandwidth chart instead of proxy selector
if (isRunning && meta.trafficSnapshots) {
// Find the traffic snapshot for this profile by matching profile_id
const snapshot = meta.trafficSnapshots[profile.id];
// Only use recent_bandwidth (last 60 seconds) - minimal data needed for mini chart
// Create a new array reference to ensure React detects changes
const bandwidthData = snapshot?.recent_bandwidth
? [...snapshot.recent_bandwidth]
: [];
@@ -1882,13 +2078,21 @@ export function ProfilesDataTable({
: "cursor-pointer hover:bg-accent/50",
)}
>
{vpnBadge && (
<Badge
variant="outline"
className="text-[10px] px-1 py-0 leading-tight"
>
{vpnBadge}
</Badge>
)}
<span
className={cn(
"text-sm",
!profileHasProxy && "text-muted-foreground",
!hasAssignment && "text-muted-foreground",
)}
>
{profileHasProxy
{hasAssignment
? trimName(displayName, 10)
: displayName}
</span>
@@ -1910,8 +2114,8 @@ export function ProfilesDataTable({
<CommandInput
placeholder={
meta.canCreateLocationProxy
? "Search proxies or countries..."
: "Search proxies..."
? "Search proxies, VPNs, or countries..."
: "Search proxies or VPNs..."
}
onFocus={() => {
if (meta.canCreateLocationProxy)
@@ -1919,7 +2123,7 @@ export function ProfilesDataTable({
}}
/>
<CommandList>
<CommandEmpty>No proxies found.</CommandEmpty>
<CommandEmpty>No proxies or VPNs found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
@@ -1930,12 +2134,12 @@ export function ProfilesDataTable({
<LuCheck
className={cn(
"mr-2 h-4 w-4",
effectiveProxyId === null
selectedId === null
? "opacity-100"
: "opacity-0",
)}
/>
No Proxy
None
</CommandItem>
{meta.storedProxies.map((proxy) => (
<CommandItem
@@ -1951,7 +2155,7 @@ export function ProfilesDataTable({
<LuCheck
className={cn(
"mr-2 h-4 w-4",
effectiveProxyId === proxy.id
effectiveProxyId === proxy.id && !effectiveVpn
? "opacity-100"
: "opacity-0",
)}
@@ -1960,6 +2164,38 @@ export function ProfilesDataTable({
</CommandItem>
))}
</CommandGroup>
{meta.vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs">
{meta.vpnConfigs.map((vpn) => (
<CommandItem
key={vpn.id}
value={`vpn-${vpn.name}`}
onSelect={() =>
void meta.handleVpnSelection(
profile.id,
vpn.id,
)
}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
effectiveVpnId === vpn.id
? "opacity-100"
: "opacity-0",
)}
/>
<Badge
variant="outline"
className="text-[10px] px-1 py-0 leading-tight mr-1"
>
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
</Badge>
{vpn.name}
</CommandItem>
))}
</CommandGroup>
)}
{meta.canCreateLocationProxy &&
meta.countries.length > 0 && (
<CommandGroup heading="Create by country">
@@ -1994,7 +2230,7 @@ export function ProfilesDataTable({
</PopoverContent>
)}
</Popover>
{profileHasProxy && effectiveProxy && !isDisabled && (
{effectiveProxy && !effectiveVpn && !isDisabled && (
<ProxyCheckButton
proxy={effectiveProxy}
profileId={profile.id}
@@ -2023,26 +2259,32 @@ export function ProfilesDataTable({
id: "sync",
header: "",
size: 24,
cell: ({ row }) => {
cell: ({ row, table }) => {
const profile = row.original;
const meta = table.options.meta as TableMeta;
const liveStatus = meta.syncStatuses[profile.id] as
| "syncing"
| "waiting"
| "synced"
| "error"
| "disabled"
| undefined;
if (!profile.sync_enabled && profile.last_sync) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex justify-center items-center w-3 h-3">
<span className="w-2 h-2 rounded-full bg-orange-500" />
</span>
</TooltipTrigger>
<TooltipContent>
Sync is disabled, last sync{" "}
{formatRelativeTime(profile.last_sync)}
</TooltipContent>
</Tooltip>
);
}
const dot = getProfileSyncStatusDot(profile, liveStatus);
if (!dot) return null;
return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex justify-center items-center w-3 h-3">
<span
className={`w-2 h-2 rounded-full ${dot.color}${dot.animate ? " animate-pulse" : ""}`}
/>
</span>
</TooltipTrigger>
<TooltipContent>{dot.tooltip}</TooltipContent>
</Tooltip>
);
},
},
{
@@ -2050,6 +2292,7 @@ export function ProfilesDataTable({
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isBrowserUpdating =
@@ -2057,6 +2300,12 @@ export function ProfilesDataTable({
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning ||
isLaunching ||
isStopping ||
isBrowserUpdating ||
isCrossOs;
const isDeleteDisabled =
isRunning || isLaunching || isStopping || isBrowserUpdating;
return (
@@ -2077,6 +2326,7 @@ export function ProfilesDataTable({
onClick={() => {
meta.onOpenTrafficDialog?.(profile.id);
}}
disabled={isCrossOs}
>
View Network
</DropdownMenuItem>
@@ -2086,7 +2336,7 @@ export function ProfilesDataTable({
meta.onToggleProfileSync?.(profile);
}
}}
disabled={!meta.crossOsUnlocked}
disabled={!meta.crossOsUnlocked || isCrossOs}
>
<span className="flex items-center gap-2">
{profile.sync_enabled ? "Disable Sync" : "Enable Sync"}
@@ -2110,6 +2360,7 @@ export function ProfilesDataTable({
onClick={() => {
meta.onConfigureCamoufox?.(profile);
}}
disabled={isDisabled}
>
Change Fingerprint
</DropdownMenuItem>
@@ -2121,6 +2372,7 @@ export function ProfilesDataTable({
onClick={() => {
meta.onCopyCookiesToProfile?.(profile);
}}
disabled={isDisabled}
>
Copy Cookies to Profile
</DropdownMenuItem>
@@ -2137,7 +2389,7 @@ export function ProfilesDataTable({
onClick={() => {
setProfileToDelete(profile);
}}
disabled={isDisabled}
disabled={isDeleteDisabled}
>
Delete
</DropdownMenuItem>
@@ -2210,7 +2462,10 @@ export function ProfilesDataTable({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="overflow-visible hover:bg-accent/50"
className={cn(
"overflow-visible hover:bg-accent/50",
isCrossOsProfile(row.original) && "opacity-60",
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="overflow-visible">
+84 -30
View File
@@ -5,6 +5,7 @@ import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
@@ -17,11 +18,13 @@ import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BrowserProfile, StoredProxy } from "@/types";
import type { BrowserProfile, StoredProxy, VpnConfig } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxyAssignmentDialogProps {
@@ -31,6 +34,7 @@ interface ProxyAssignmentDialogProps {
onAssignmentComplete: () => void;
profiles?: BrowserProfile[];
storedProxies?: StoredProxy[];
vpnConfigs?: VpnConfig[];
}
export function ProxyAssignmentDialog({
@@ -40,11 +44,28 @@ export function ProxyAssignmentDialog({
onAssignmentComplete,
profiles = [],
storedProxies = [],
vpnConfigs = [],
}: ProxyAssignmentDialogProps) {
const [selectedProxyId, setSelectedProxyId] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectionType, setSelectionType] = useState<"none" | "proxy" | "vpn">(
"none",
);
const [isAssigning, setIsAssigning] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleValueChange = useCallback((value: string) => {
if (value === "none") {
setSelectedId(null);
setSelectionType("none");
} else if (value.startsWith("vpn-")) {
setSelectedId(value.slice(4));
setSelectionType("vpn");
} else {
setSelectedId(value);
setSelectionType("proxy");
}
}, []);
const handleAssign = useCallback(async () => {
setIsAssigning(true);
setError(null);
@@ -60,24 +81,29 @@ export function ProxyAssignmentDialog({
return;
}
// Update each profile's proxy sequentially to avoid file locking issues
for (const profileId of validProfiles) {
await invoke("update_profile_proxy", {
profileId,
proxyId: selectedProxyId,
});
if (selectionType === "vpn") {
await invoke("update_profile_vpn", {
profileId,
vpnId: selectedId,
});
} else {
await invoke("update_profile_proxy", {
profileId,
proxyId: selectionType === "proxy" ? selectedId : null,
});
}
}
// Notify other parts of the app so usage counts and lists refresh
await emit("profile-updated");
onAssignmentComplete();
onClose();
} catch (err) {
console.error("Failed to assign proxies to profiles:", err);
console.error("Failed to assign proxy/VPN to profiles:", err);
const errorMessage =
err instanceof Error
? err.message
: "Failed to assign proxies to profiles";
: "Failed to assign proxy/VPN to profiles";
setError(errorMessage);
toast.error(errorMessage);
} finally {
@@ -85,7 +111,8 @@ export function ProxyAssignmentDialog({
}
}, [
selectedProfiles,
selectedProxyId,
selectedId,
selectionType,
profiles,
onAssignmentComplete,
onClose,
@@ -93,18 +120,27 @@ export function ProxyAssignmentDialog({
useEffect(() => {
if (isOpen) {
setSelectedProxyId(null);
setSelectedId(null);
setSelectionType("none");
setError(null);
}
}, [isOpen]);
const selectValue =
selectionType === "none"
? "none"
: selectionType === "vpn"
? `vpn-${selectedId}`
: (selectedId ?? "none");
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Assign Proxy</DialogTitle>
<DialogTitle>Assign Proxy / VPN</DialogTitle>
<DialogDescription>
Assign a proxy to {selectedProfiles.length} selected profile(s).
Assign a proxy or VPN to {selectedProfiles.length} selected
profile(s).
</DialogDescription>
</DialogHeader>
@@ -120,7 +156,7 @@ export function ProxyAssignmentDialog({
const displayName = profile ? profile.name : profileId;
return (
<li key={profileId} className="truncate">
{displayName}
&bull; {displayName}
</li>
);
})}
@@ -129,24 +165,42 @@ export function ProxyAssignmentDialog({
</div>
<div className="space-y-2">
<Label htmlFor="proxy-select">Assign Proxy:</Label>
<Select
value={selectedProxyId || "none"}
onValueChange={(value) => {
setSelectedProxyId(value === "none" ? null : value);
}}
>
<Label htmlFor="proxy-vpn-select">Assign Proxy / VPN:</Label>
<Select value={selectValue} onValueChange={handleValueChange}>
<SelectTrigger>
<SelectValue placeholder="Select a proxy" />
<SelectValue placeholder="Select a proxy or VPN" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No Proxy</SelectItem>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
{proxy.is_cloud_managed ? " (Included)" : ""}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
{storedProxies.length > 0 && (
<SelectGroup>
<SelectLabel>Proxies</SelectLabel>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
{proxy.is_cloud_managed ? " (Included)" : ""}
</SelectItem>
))}
</SelectGroup>
)}
{vpnConfigs.length > 0 && (
<SelectGroup>
<SelectLabel>VPNs</SelectLabel>
{vpnConfigs.map((vpn) => (
<SelectItem key={vpn.id} value={`vpn-${vpn.id}`}>
<span className="flex items-center gap-1">
<Badge
variant="outline"
className="text-[10px] px-1 py-0 leading-tight"
>
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
</Badge>
{vpn.name}
</span>
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</div>
+11 -221
View File
@@ -3,7 +3,7 @@
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { LuShield, LuUpload } from "react-icons/lu";
import { LuUpload } from "react-icons/lu";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import {
@@ -22,8 +22,6 @@ import type {
ParsedProxyLine,
ProxyImportResult,
ProxyParseResult,
VpnImportResult,
VpnType,
} from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -32,13 +30,7 @@ interface ProxyImportDialogProps {
onClose: () => void;
}
type ImportStep =
| "dropzone"
| "preview"
| "ambiguous"
| "result"
| "vpn-preview"
| "vpn-result";
type ImportStep = "dropzone" | "preview" | "ambiguous" | "result";
interface AmbiguousProxy {
line: string;
@@ -46,13 +38,6 @@ interface AmbiguousProxy {
selectedFormat?: string;
}
interface VpnPreviewData {
content: string;
filename: string;
detectedType: VpnType | null;
endpoint: string | null;
}
export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
const [step, setStep] = useState<ImportStep>("dropzone");
const [isDragOver, setIsDragOver] = useState(false);
@@ -68,11 +53,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
);
const [isImporting, setIsImporting] = useState(false);
const [namePrefix, setNamePrefix] = useState("Imported");
// VPN import state
const [vpnPreview, setVpnPreview] = useState<VpnPreviewData | null>(null);
const [vpnName, setVpnName] = useState("");
const [vpnImportResult, setVpnImportResult] =
useState<VpnImportResult | null>(null);
const os = getCurrentOS();
const modKey = os === "macos" ? "⌘" : "Ctrl";
@@ -86,76 +66,11 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
setImportResult(null);
setIsImporting(false);
setNamePrefix("Imported");
// Reset VPN state
setVpnPreview(null);
setVpnName("");
setVpnImportResult(null);
}, []);
// Detect VPN type from content
const detectVpnType = useCallback(
(
content: string,
filename: string,
): { isVpn: boolean; type: VpnType | null; endpoint: string | null } => {
const lowerFilename = filename.toLowerCase();
// Check for WireGuard config
if (
lowerFilename.endsWith(".conf") &&
content.includes("[Interface]") &&
content.includes("[Peer]")
) {
// Extract endpoint from WireGuard config
const endpointMatch = content.match(/Endpoint\s*=\s*([^\s\n]+)/i);
return {
isVpn: true,
type: "WireGuard",
endpoint: endpointMatch ? endpointMatch[1] : null,
};
}
// Check for OpenVPN config
if (
lowerFilename.endsWith(".ovpn") ||
(content.includes("remote ") &&
(content.includes("client") || content.includes("dev tun")))
) {
// Extract remote from OpenVPN config
const remoteMatch = content.match(/remote\s+(\S+)(?:\s+(\d+))?/i);
const endpoint = remoteMatch
? `${remoteMatch[1]}${remoteMatch[2] ? `:${remoteMatch[2]}` : ""}`
: null;
return { isVpn: true, type: "OpenVPN", endpoint };
}
return { isVpn: false, type: null, endpoint: null };
},
[],
);
const processContent = useCallback(
async (content: string, isJson: boolean, filename: string = "") => {
async (content: string, isJson: boolean, _filename: string = "") => {
try {
// Check if it's a VPN config
const vpnDetection = detectVpnType(content, filename);
if (vpnDetection.isVpn) {
setVpnPreview({
content,
filename,
detectedType: vpnDetection.type,
endpoint: vpnDetection.endpoint,
});
// Generate default name from filename
const baseName = filename
.replace(/\.(conf|ovpn)$/i, "")
.replace(/_/g, " ")
.replace(/-/g, " ");
setVpnName(baseName || `${vpnDetection.type} VPN`);
setStep("vpn-preview");
return;
}
if (isJson) {
setIsImporting(true);
const result = await invoke<ProxyImportResult>(
@@ -213,7 +128,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
setIsImporting(false);
}
},
[detectVpnType],
[],
);
const handleFileRead = useCallback(
@@ -239,17 +154,13 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
const files = Array.from(e.dataTransfer.files);
const validFile = files.find(
(f) =>
f.name.endsWith(".json") ||
f.name.endsWith(".txt") ||
f.name.endsWith(".conf") || // WireGuard
f.name.endsWith(".ovpn"), // OpenVPN
(f) => f.name.endsWith(".json") || f.name.endsWith(".txt"),
);
if (validFile) {
handleFileRead(validFile);
} else {
toast.error("Please drop a .json, .txt, .conf, or .ovpn file");
toast.error("Please drop a .json or .txt file");
}
},
[handleFileRead],
@@ -311,33 +222,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
}
}, [parsedProxies, namePrefix]);
const handleVpnImport = useCallback(async () => {
if (!vpnPreview) return;
setIsImporting(true);
try {
const result = await invoke<VpnImportResult>("import_vpn_config", {
content: vpnPreview.content,
filename: vpnPreview.filename,
name: vpnName.trim() || null,
});
setVpnImportResult(result);
setStep("vpn-result");
if (result.success) {
await emit("vpn-configs-changed");
}
} catch (error) {
console.error("Failed to import VPN config:", error);
toast.error(
error instanceof Error ? error.message : "Failed to import VPN config",
);
} finally {
setIsImporting(false);
}
}, [vpnPreview, vpnName]);
const handleAmbiguousFormatSelect = useCallback(
(index: number, format: string) => {
setAmbiguousProxies((prev) =>
@@ -389,20 +273,13 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{step === "vpn-preview" || step === "vpn-result"
? "Import VPN Config"
: "Import Proxies"}
</DialogTitle>
<DialogTitle>Import Proxies</DialogTitle>
<DialogDescription>
{step === "dropzone" &&
"Import proxies from a JSON or TXT file, or VPN configs (.conf/.ovpn)"}
{step === "dropzone" && "Import proxies from a JSON or TXT file"}
{step === "preview" && "Review the proxies to import"}
{step === "ambiguous" &&
"Some proxies have ambiguous formats. Please select the correct format."}
{step === "result" && "Import completed"}
{step === "vpn-preview" && "Review the VPN configuration to import"}
{step === "vpn-result" && "VPN import completed"}
</DialogDescription>
</DialogHeader>
@@ -432,14 +309,14 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
Drop a proxy or VPN config file
Drop a proxy config file
<br />
<span className="text-xs">(.json, .txt, .conf, .ovpn)</span>
<span className="text-xs">(.json, .txt)</span>
</p>
<input
id="proxy-file-input"
type="file"
accept=".json,.txt,.conf,.ovpn"
accept=".json,.txt"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
@@ -594,75 +471,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
</div>
)}
{step === "vpn-preview" && vpnPreview && (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
<LuShield className="w-8 h-8 text-primary" />
<div>
<div className="font-medium">
{vpnPreview.detectedType} Configuration
</div>
{vpnPreview.endpoint && (
<div className="text-sm text-muted-foreground">
Endpoint: {vpnPreview.endpoint}
</div>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="vpn-name">VPN Name</Label>
<Input
id="vpn-name"
placeholder="My VPN"
value={vpnName}
onChange={(e) => setVpnName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Config Preview</Label>
<ScrollArea className="h-[150px] border rounded-md">
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all">
{vpnPreview.content.slice(0, 1000)}
{vpnPreview.content.length > 1000 && "..."}
</pre>
</ScrollArea>
</div>
</div>
)}
{step === "vpn-result" && vpnImportResult && (
<div className="space-y-4">
<div
className={`p-4 rounded-lg ${vpnImportResult.success ? "bg-green-500/10" : "bg-red-500/10"}`}
>
{vpnImportResult.success ? (
<div className="flex items-center gap-3">
<LuShield className="w-8 h-8 text-green-600 dark:text-green-400" />
<div>
<div className="font-medium text-green-600 dark:text-green-400">
VPN Imported Successfully
</div>
<div className="text-sm text-muted-foreground">
{vpnImportResult.name} ({vpnImportResult.vpn_type})
</div>
</div>
</div>
) : (
<div className="space-y-2">
<div className="font-medium text-red-600 dark:text-red-400">
Import Failed
</div>
<div className="text-sm text-red-600 dark:text-red-400">
{vpnImportResult.error}
</div>
</div>
)}
</div>
</div>
)}
<DialogFooter>
{step === "dropzone" && (
<RippleButton variant="outline" onClick={handleClose}>
@@ -702,24 +510,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
{step === "result" && (
<RippleButton onClick={handleClose}>Done</RippleButton>
)}
{step === "vpn-preview" && (
<>
<RippleButton variant="outline" onClick={resetState}>
Back
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => void handleVpnImport()}
>
Import VPN
</LoadingButton>
</>
)}
{step === "vpn-result" && (
<RippleButton onClick={handleClose}>Done</RippleButton>
)}
</DialogFooter>
</DialogContent>
</Dialog>
+542 -207
View File
@@ -30,26 +30,31 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { ProxyCheckResult, StoredProxy } from "@/types";
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types";
import { FlagIcon } from "./flag-icon";
import { LocationProxyDialog } from "./location-proxy-dialog";
import { ProxyCheckButton } from "./proxy-check-button";
import { RippleButton } from "./ui/ripple";
import { VpnCheckButton } from "./vpn-check-button";
import { VpnFormDialog } from "./vpn-form-dialog";
import { VpnImportDialog } from "./vpn-import-dialog";
type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
function getSyncStatusDot(
proxy: StoredProxy,
item: { sync_enabled?: boolean; last_sync?: number },
liveStatus: SyncStatus | undefined,
): { color: string; tooltip: string; animate: boolean } {
const status = liveStatus ?? (proxy.sync_enabled ? "synced" : "disabled");
const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled");
switch (status) {
case "syncing":
@@ -57,8 +62,8 @@ function getSyncStatusDot(
case "synced":
return {
color: "bg-green-500",
tooltip: proxy.last_sync
? `Synced ${new Date(proxy.last_sync * 1000).toLocaleString()}`
tooltip: item.last_sync
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}`
: "Synced",
animate: false,
};
@@ -84,6 +89,7 @@ export function ProxyManagementDialog({
isOpen,
onClose,
}: ProxyManagementDialogProps) {
// Proxy state
const [showProxyForm, setShowProxyForm] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [showExportDialog, setShowExportDialog] = useState(false);
@@ -103,7 +109,23 @@ export function ProxyManagementDialog({
{},
);
// VPN state
const [showVpnForm, setShowVpnForm] = useState(false);
const [showVpnImportDialog, setShowVpnImportDialog] = useState(false);
const [editingVpn, setEditingVpn] = useState<VpnConfig | null>(null);
const [vpnToDelete, setVpnToDelete] = useState<VpnConfig | null>(null);
const [isDeletingVpn, setIsDeletingVpn] = useState(false);
const [checkingVpnId, setCheckingVpnId] = useState<string | null>(null);
const [vpnSyncStatus, setVpnSyncStatus] = useState<
Record<string, SyncStatus>
>({});
const [vpnInUse, setVpnInUse] = useState<Record<string, boolean>>({});
const [isTogglingVpnSync, setIsTogglingVpnSync] = useState<
Record<string, boolean>
>({});
const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents();
const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents();
const [cloudProxyUsage, setCloudProxyUsage] = useState<{
used_mb: number;
limit_mb: number;
@@ -158,6 +180,29 @@ export function ProxyManagementDialog({
};
}, []);
// Listen for VPN sync status events
useEffect(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<{ id: string; status: string }>(
"vpn-sync-status",
(event) => {
const { id, status } = event.payload;
setVpnSyncStatus((prev) => ({
...prev,
[id]: status as SyncStatus,
}));
},
);
};
void setupListener();
return () => {
unlisten?.();
};
}, []);
// Load cached check results on mount and when proxies change
useEffect(() => {
const loadCachedResults = async () => {
@@ -190,8 +235,30 @@ export function ProxyManagementDialog({
}
}, [storedProxies]);
// Load VPN in-use status
useEffect(() => {
const loadVpnInUse = async () => {
const inUse: Record<string, boolean> = {};
for (const vpn of vpnConfigs) {
try {
const inUseBySynced = await invoke<boolean>(
"is_vpn_in_use_by_synced_profile",
{ vpnId: vpn.id },
);
inUse[vpn.id] = inUseBySynced;
} catch (_error) {
// Ignore errors
}
}
setVpnInUse(inUse);
};
if (vpnConfigs.length > 0) {
void loadVpnInUse();
}
}, [vpnConfigs]);
// Proxy handlers
const handleDeleteProxy = useCallback((proxy: StoredProxy) => {
// Open in-app confirmation dialog
setProxyToDelete(proxy);
}, []);
@@ -245,106 +312,377 @@ export function ProxyManagementDialog({
}
}, []);
// VPN handlers
const handleDeleteVpn = useCallback((vpn: VpnConfig) => {
setVpnToDelete(vpn);
}, []);
const handleConfirmDeleteVpn = useCallback(async () => {
if (!vpnToDelete) return;
setIsDeletingVpn(true);
try {
await invoke("delete_vpn_config", { vpnId: vpnToDelete.id });
toast.success("VPN deleted successfully");
await emit("vpn-configs-changed");
} catch (error) {
console.error("Failed to delete VPN:", error);
toast.error("Failed to delete VPN");
} finally {
setIsDeletingVpn(false);
setVpnToDelete(null);
}
}, [vpnToDelete]);
const handleCreateVpn = useCallback(() => {
setEditingVpn(null);
setShowVpnForm(true);
}, []);
const handleEditVpn = useCallback((vpn: VpnConfig) => {
setEditingVpn(vpn);
setShowVpnForm(true);
}, []);
const handleVpnFormClose = useCallback(() => {
setShowVpnForm(false);
setEditingVpn(null);
}, []);
const handleToggleVpnSync = useCallback(async (vpn: VpnConfig) => {
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: true }));
try {
await invoke("set_vpn_sync_enabled", {
vpnId: vpn.id,
enabled: !vpn.sync_enabled,
});
showSuccessToast(vpn.sync_enabled ? "Sync disabled" : "Sync enabled");
await emit("vpn-configs-changed");
} catch (error) {
console.error("Failed to toggle VPN sync:", error);
showErrorToast(
error instanceof Error ? error.message : "Failed to update sync",
);
} finally {
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: false }));
}
}, []);
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Proxy Management</DialogTitle>
<DialogTitle>Proxies & VPNs</DialogTitle>
<DialogDescription>
Manage your saved proxy configurations for reuse across profiles
Manage your proxy and VPN configurations for reuse across profiles
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Proxy actions */}
<div className="flex justify-between items-center">
<div className="flex gap-2">
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowImportDialog(true)}
className="flex gap-2 items-center"
>
<LuUpload className="w-4 h-4" />
Import
</RippleButton>
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowExportDialog(true)}
className="flex gap-2 items-center"
disabled={storedProxies.length === 0}
>
<LuDownload className="w-4 h-4" />
Export
</RippleButton>
<Tabs defaultValue="proxies">
<TabsList className="w-full">
<TabsTrigger value="proxies" className="flex-1">
Proxies
</TabsTrigger>
<TabsTrigger value="vpns" className="flex-1">
VPNs
</TabsTrigger>
</TabsList>
<TabsContent value="proxies">
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex gap-2">
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowImportDialog(true)}
className="flex gap-2 items-center"
>
<LuUpload className="w-4 h-4" />
Import
</RippleButton>
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowExportDialog(true)}
className="flex gap-2 items-center"
disabled={storedProxies.length === 0}
>
<LuDownload className="w-4 h-4" />
Export
</RippleButton>
</div>
<div className="flex gap-2">
{storedProxies.some((p) => p.is_cloud_managed) && (
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowLocationDialog(true)}
className="flex gap-2 items-center"
>
<GoGlobe className="w-4 h-4" />
Location
</RippleButton>
)}
<RippleButton
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
</div>
</div>
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading proxies...
</div>
) : storedProxies.length === 0 ? (
<div className="text-sm text-muted-foreground">
No proxies created yet. Create your first proxy using the
button above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-20">Usage</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{storedProxies.map((proxy) => {
const isCloud = proxy.is_cloud_managed === true;
const syncDot = getSyncStatusDot(
proxy,
proxySyncStatus[proxy.id],
);
const isDerived = proxy.is_cloud_derived === true;
return (
<TableRow key={proxy.id}>
<TableCell className="font-medium">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
{isDerived && proxy.geo_country && (
<FlagIcon
countryCode={proxy.geo_country}
className="shrink-0"
/>
)}
{!isCloud && !isDerived && (
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate
? "animate-pulse"
: ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
)}
{proxy.name}
</div>
{isCloud && cloudProxyUsage && (
<span className="text-xs text-muted-foreground">
{cloudProxyUsage.used_mb} /{" "}
{cloudProxyUsage.limit_mb} MB used
</span>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">
{proxyUsage[proxy.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
{isCloud ? (
<Badge variant="outline">Cloud</Badge>
) : (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={proxy.sync_enabled}
onCheckedChange={() =>
handleToggleSync(proxy)
}
disabled={
isTogglingSync[proxy.id] ||
proxyInUse[proxy.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{proxyInUse[proxy.id] ? (
<p>
Sync cannot be disabled while this
proxy is used by synced profiles
</p>
) : (
<p>
{proxy.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
</TooltipContent>
</Tooltip>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<ProxyCheckButton
proxy={proxy}
profileId={proxy.id}
checkingProfileId={checkingProxyId}
cachedResult={proxyCheckResults[proxy.id]}
setCheckingProfileId={setCheckingProxyId}
onCheckComplete={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
onCheckFailed={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
/>
{!isCloud && !isDerived && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleEditProxy(proxy)
}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
)}
{!isCloud && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleDeleteProxy(proxy)
}
disabled={
(proxyUsage[proxy.id] ?? 0) > 0
}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
<p>
Cannot delete: in use by{" "}
{proxyUsage[proxy.id]} profile
{proxyUsage[proxy.id] > 1
? "s"
: ""}
</p>
) : (
<p>Delete proxy</p>
)}
</TooltipContent>
</Tooltip>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
<div className="flex gap-2">
{storedProxies.some((p) => p.is_cloud_managed) && (
</TabsContent>
<TabsContent value="vpns">
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex gap-2">
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowVpnImportDialog(true)}
className="flex gap-2 items-center"
>
<LuUpload className="w-4 h-4" />
Import
</RippleButton>
</div>
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowLocationDialog(true)}
onClick={handleCreateVpn}
className="flex gap-2 items-center"
>
<GoGlobe className="w-4 h-4" />
Location
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
)}
<RippleButton
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
</div>
</div>
</div>
{/* Proxies list */}
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading proxies...
</div>
) : storedProxies.length === 0 ? (
<div className="text-sm text-muted-foreground">
No proxies created yet. Create your first proxy using the button
above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-20">Usage</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{storedProxies.map((proxy) => {
const isCloud = proxy.is_cloud_managed === true;
const syncDot = getSyncStatusDot(
proxy,
proxySyncStatus[proxy.id],
);
const isDerived = proxy.is_cloud_derived === true;
return (
<TableRow key={proxy.id}>
<TableCell className="font-medium">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
{isDerived && proxy.geo_country && (
<FlagIcon
countryCode={proxy.geo_country}
className="shrink-0"
/>
)}
{!isCloud && !isDerived && (
{isLoadingVpns ? (
<div className="text-sm text-muted-foreground">
Loading VPNs...
</div>
) : vpnConfigs.length === 0 ? (
<div className="text-sm text-muted-foreground">
No VPN configs created yet. Import or create one using the
buttons above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-16">Type</TableHead>
<TableHead className="w-20">Usage</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vpnConfigs.map((vpn) => {
const syncDot = getSyncStatusDot(
vpn,
vpnSyncStatus[vpn.id],
);
return (
<TableRow key={vpn.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div
@@ -359,137 +697,115 @@ export function ProxyManagementDialog({
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
)}
{proxy.name}
</div>
{isCloud && cloudProxyUsage && (
<span className="text-xs text-muted-foreground">
{cloudProxyUsage.used_mb} /{" "}
{cloudProxyUsage.limit_mb} MB used
</span>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">
{proxyUsage[proxy.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
{isCloud ? (
<Badge variant="outline">Cloud</Badge>
) : (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={proxy.sync_enabled}
onCheckedChange={() =>
handleToggleSync(proxy)
}
disabled={
isTogglingSync[proxy.id] ||
proxyInUse[proxy.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{proxyInUse[proxy.id] ? (
<p>
Sync cannot be disabled while this proxy
is used by synced profiles
</p>
) : (
<p>
{proxy.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
</TooltipContent>
</Tooltip>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<ProxyCheckButton
proxy={proxy}
profileId={proxy.id}
checkingProfileId={checkingProxyId}
cachedResult={proxyCheckResults[proxy.id]}
setCheckingProfileId={setCheckingProxyId}
onCheckComplete={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
onCheckFailed={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
/>
{!isCloud && !isDerived && (
{vpn.name}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}
</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">
{vpnUsage[vpn.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditProxy(proxy)}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
)}
{!isCloud && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleDeleteProxy(proxy)
<div className="flex items-center">
<Checkbox
checked={vpn.sync_enabled}
onCheckedChange={() =>
handleToggleVpnSync(vpn)
}
disabled={
(proxyUsage[proxy.id] ?? 0) > 0
isTogglingVpnSync[vpn.id] ||
vpnInUse[vpn.id]
}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</span>
/>
</div>
</TooltipTrigger>
<TooltipContent>
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
{vpnInUse[vpn.id] ? (
<p>
Cannot delete: in use by{" "}
{proxyUsage[proxy.id]} profile
{proxyUsage[proxy.id] > 1 ? "s" : ""}
Sync cannot be disabled while this VPN
is used by synced profiles
</p>
) : (
<p>Delete proxy</p>
<p>
{vpn.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
</TooltipContent>
</Tooltip>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</TableCell>
<TableCell>
<div className="flex gap-1">
<VpnCheckButton
vpnId={vpn.id}
vpnName={vpn.name}
checkingVpnId={checkingVpnId}
setCheckingVpnId={setCheckingVpnId}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditVpn(vpn)}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit VPN</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteVpn(vpn)}
disabled={
(vpnUsage[vpn.id] ?? 0) > 0
}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
<p>
Cannot delete: in use by{" "}
{vpnUsage[vpn.id]} profile
{vpnUsage[vpn.id] > 1 ? "s" : ""}
</p>
) : (
<p>Delete VPN</p>
)}
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
)}
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
@@ -525,6 +841,25 @@ export function ProxyManagementDialog({
isOpen={showLocationDialog}
onClose={() => setShowLocationDialog(false)}
/>
<VpnFormDialog
isOpen={showVpnForm}
onClose={handleVpnFormClose}
editingVpn={editingVpn}
/>
<DeleteConfirmationDialog
isOpen={vpnToDelete !== null}
onClose={() => setVpnToDelete(null)}
onConfirm={handleConfirmDeleteVpn}
title="Delete VPN"
description={`This action cannot be undone. This will permanently delete the VPN "${vpnToDelete?.name ?? ""}".`}
confirmButtonText="Delete"
isLoading={isDeletingVpn}
/>
<VpnImportDialog
isOpen={showVpnImportDialog}
onClose={() => setShowVpnImportDialog(false)}
/>
</>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
interface UnsyncedEntityCounts {
proxies: number;
groups: number;
vpns: number;
}
interface SyncAllDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
const { t } = useTranslation();
const [counts, setCounts] = useState<UnsyncedEntityCounts | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isEnabling, setIsEnabling] = useState(false);
const loadCounts = useCallback(async () => {
setIsLoading(true);
try {
const result = await invoke<UnsyncedEntityCounts>(
"get_unsynced_entity_counts",
);
setCounts(result);
} catch (error) {
console.error("Failed to get unsynced entity counts:", error);
setCounts(null);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
if (isOpen) {
void loadCounts();
}
}, [isOpen, loadCounts]);
const handleEnableAll = useCallback(async () => {
setIsEnabling(true);
try {
await invoke("enable_sync_for_all_entities");
showSuccessToast(t("syncAll.success"));
onClose();
} catch (error) {
console.error("Failed to enable sync for all entities:", error);
showErrorToast(String(error));
} finally {
setIsEnabling(false);
}
}, [onClose, t]);
const totalCount =
(counts?.proxies ?? 0) + (counts?.groups ?? 0) + (counts?.vpns ?? 0);
// Don't show if there's nothing to sync
if (!isLoading && totalCount === 0) {
return null;
}
const parts: string[] = [];
if (counts?.proxies && counts.proxies > 0) {
parts.push(t("syncAll.proxies", { count: counts.proxies }));
}
if (counts?.groups && counts.groups > 0) {
parts.push(t("syncAll.groups", { count: counts.groups }));
}
if (counts?.vpns && counts.vpns > 0) {
parts.push(t("syncAll.vpns", { count: counts.vpns }));
}
return (
<Dialog open={isOpen && totalCount > 0} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("syncAll.title")}</DialogTitle>
<DialogDescription>{t("syncAll.description")}</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="py-4">
<p className="text-sm text-muted-foreground">
{t("syncAll.itemsList", { items: parts.join(", ") })}
</p>
</div>
)}
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={onClose} disabled={isEnabling}>
{t("syncAll.skip")}
</Button>
<LoadingButton
onClick={handleEnableAll}
isLoading={isEnabling}
disabled={isLoading}
>
{t("syncAll.enableAll")}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+3 -3
View File
@@ -28,7 +28,7 @@ import type { SyncSettings } from "@/types";
interface SyncConfigDialogProps {
isOpen: boolean;
onClose: () => void;
onClose: (loginOccurred?: boolean) => void;
}
export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
@@ -179,8 +179,8 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
} catch (e) {
console.error("Failed to restart sync service:", e);
}
// Auto-close dialog after successful login
onClose();
// Auto-close dialog after successful login, signal that login occurred
onClose(true);
} catch (error) {
console.error("OTP verification failed:", error);
showErrorToast(String(error));
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import { FiCheck } from "react-icons/fi";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatRelativeTime } from "@/lib/flag-utils";
import type { ProxyCheckResult } from "@/types";
interface VpnCheckButtonProps {
vpnId: string;
vpnName: string;
checkingVpnId: string | null;
setCheckingVpnId: (id: string | null) => void;
disabled?: boolean;
}
export function VpnCheckButton({
vpnId,
vpnName,
checkingVpnId,
setCheckingVpnId,
disabled = false,
}: VpnCheckButtonProps) {
const [result, setResult] = React.useState<ProxyCheckResult | undefined>();
const handleCheck = React.useCallback(async () => {
if (checkingVpnId === vpnId) return;
setCheckingVpnId(vpnId);
try {
const checkResult = await invoke<ProxyCheckResult>("check_vpn_validity", {
vpnId,
});
setResult(checkResult);
if (checkResult.is_valid) {
toast.success(`VPN "${vpnName}" configuration is valid`);
} else {
toast.error(`VPN "${vpnName}" configuration is invalid`);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`VPN check failed: ${errorMessage}`);
setResult({
ip: "",
timestamp: Math.floor(Date.now() / 1000),
is_valid: false,
});
} finally {
setCheckingVpnId(null);
}
}, [vpnId, vpnName, checkingVpnId, setCheckingVpnId]);
const isCurrentlyChecking = checkingVpnId === vpnId;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={handleCheck}
disabled={isCurrentlyChecking || disabled}
>
{isCurrentlyChecking ? (
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
) : result?.is_valid ? (
<FiCheck className="w-3 h-3 text-green-500" />
) : result && !result.is_valid ? (
<span className="text-destructive text-sm"></span>
) : (
<FiCheck className="w-3 h-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isCurrentlyChecking ? (
<p>Checking VPN config...</p>
) : result?.is_valid ? (
<div className="space-y-1">
<p>Configuration valid</p>
<p className="text-xs text-primary-foreground/70">
Checked {formatRelativeTime(result.timestamp)}
</p>
</div>
) : result && !result.is_valid ? (
<div>
<p>Configuration invalid</p>
<p className="text-xs text-primary-foreground/70">
Checked {formatRelativeTime(result.timestamp)}
</p>
</div>
) : (
<p>Check VPN config validity</p>
)}
</TooltipContent>
</Tooltip>
);
}
+489
View File
@@ -0,0 +1,489 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RippleButton } from "@/components/ui/ripple";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { VpnConfig, VpnType } from "@/types";
interface VpnFormDialogProps {
isOpen: boolean;
onClose: () => void;
editingVpn?: VpnConfig | null;
}
interface WireGuardFormData {
name: string;
privateKey: string;
address: string;
dns: string;
mtu: string;
peerPublicKey: string;
peerEndpoint: string;
allowedIps: string;
persistentKeepalive: string;
presharedKey: string;
}
interface OpenVpnFormData {
name: string;
rawConfig: string;
}
const defaultWireGuardForm: WireGuardFormData = {
name: "",
privateKey: "",
address: "",
dns: "",
mtu: "",
peerPublicKey: "",
peerEndpoint: "",
allowedIps: "0.0.0.0/0, ::/0",
persistentKeepalive: "",
presharedKey: "",
};
const defaultOpenVpnForm: OpenVpnFormData = {
name: "",
rawConfig: "",
};
function buildWireGuardConfig(form: WireGuardFormData): string {
const lines: string[] = ["[Interface]"];
lines.push(`PrivateKey = ${form.privateKey.trim()}`);
lines.push(`Address = ${form.address.trim()}`);
if (form.dns.trim()) lines.push(`DNS = ${form.dns.trim()}`);
if (form.mtu.trim()) lines.push(`MTU = ${form.mtu.trim()}`);
lines.push("");
lines.push("[Peer]");
lines.push(`PublicKey = ${form.peerPublicKey.trim()}`);
lines.push(`Endpoint = ${form.peerEndpoint.trim()}`);
lines.push(`AllowedIPs = ${form.allowedIps.trim()}`);
if (form.persistentKeepalive.trim())
lines.push(`PersistentKeepalive = ${form.persistentKeepalive.trim()}`);
if (form.presharedKey.trim())
lines.push(`PresharedKey = ${form.presharedKey.trim()}`);
return lines.join("\n");
}
export function VpnFormDialog({
isOpen,
onClose,
editingVpn,
}: VpnFormDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [vpnType, setVpnType] = useState<VpnType>("WireGuard");
const [wireGuardForm, setWireGuardForm] =
useState<WireGuardFormData>(defaultWireGuardForm);
const [openVpnForm, setOpenVpnForm] =
useState<OpenVpnFormData>(defaultOpenVpnForm);
const resetForms = useCallback(() => {
setVpnType("WireGuard");
setWireGuardForm(defaultWireGuardForm);
setOpenVpnForm(defaultOpenVpnForm);
}, []);
useEffect(() => {
if (isOpen) {
if (editingVpn) {
setVpnType(editingVpn.vpn_type);
if (editingVpn.vpn_type === "WireGuard") {
setWireGuardForm({ ...defaultWireGuardForm, name: editingVpn.name });
} else {
setOpenVpnForm({ name: editingVpn.name, rawConfig: "" });
}
} else {
resetForms();
}
}
}, [isOpen, editingVpn, resetForms]);
const handleClose = useCallback(() => {
if (!isSubmitting) {
onClose();
}
}, [isSubmitting, onClose]);
const handleSubmit = useCallback(async () => {
if (editingVpn) {
const name =
vpnType === "WireGuard"
? wireGuardForm.name.trim()
: openVpnForm.name.trim();
if (!name) {
toast.error("VPN name is required");
return;
}
setIsSubmitting(true);
try {
await invoke("update_vpn_config", {
vpnId: editingVpn.id,
name,
});
await emit("vpn-configs-changed");
toast.success("VPN updated successfully");
onClose();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to update VPN: ${errorMessage}`);
} finally {
setIsSubmitting(false);
}
return;
}
if (vpnType === "WireGuard") {
const { name, privateKey, address, peerPublicKey, peerEndpoint } =
wireGuardForm;
if (!name.trim()) {
toast.error("VPN name is required");
return;
}
if (!privateKey.trim()) {
toast.error("Private key is required");
return;
}
if (!address.trim()) {
toast.error("Address is required");
return;
}
if (!peerPublicKey.trim()) {
toast.error("Peer public key is required");
return;
}
if (!peerEndpoint.trim()) {
toast.error("Peer endpoint is required");
return;
}
setIsSubmitting(true);
try {
const configData = buildWireGuardConfig(wireGuardForm);
await invoke("create_vpn_config_manual", {
name: name.trim(),
vpnType: "WireGuard",
configData,
});
await emit("vpn-configs-changed");
toast.success("WireGuard VPN created successfully");
onClose();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to create VPN: ${errorMessage}`);
} finally {
setIsSubmitting(false);
}
} else {
const { name, rawConfig } = openVpnForm;
if (!name.trim()) {
toast.error("VPN name is required");
return;
}
if (!rawConfig.trim()) {
toast.error("OpenVPN config content is required");
return;
}
setIsSubmitting(true);
try {
await invoke("create_vpn_config_manual", {
name: name.trim(),
vpnType: "OpenVPN",
configData: rawConfig,
});
await emit("vpn-configs-changed");
toast.success("OpenVPN configuration created successfully");
onClose();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to create VPN: ${errorMessage}`);
} finally {
setIsSubmitting(false);
}
}
}, [editingVpn, vpnType, wireGuardForm, openVpnForm, onClose]);
const updateWireGuard = useCallback(
(field: keyof WireGuardFormData, value: string) => {
setWireGuardForm((prev) => ({ ...prev, [field]: value }));
},
[],
);
const updateOpenVpn = useCallback(
(field: keyof OpenVpnFormData, value: string) => {
setOpenVpnForm((prev) => ({ ...prev, [field]: value }));
},
[],
);
const dialogTitle = editingVpn
? "Edit VPN"
: vpnType === "WireGuard"
? "Create WireGuard VPN"
: "Create OpenVPN Configuration";
const dialogDescription = editingVpn
? "Update the name of your VPN configuration."
: vpnType === "WireGuard"
? "Enter your WireGuard interface and peer details."
: "Paste your .ovpn configuration file content.";
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription>{dialogDescription}</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh] pr-4">
<div className="grid gap-4 py-2">
{!editingVpn && (
<div className="grid gap-2">
<Label>VPN Type</Label>
<Select
value={vpnType}
onValueChange={(value) => setVpnType(value as VpnType)}
disabled={isSubmitting}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select VPN type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="WireGuard">WireGuard</SelectItem>
<SelectItem value="OpenVPN">OpenVPN</SelectItem>
</SelectContent>
</Select>
</div>
)}
{vpnType === "WireGuard" && (
<>
<div className="grid gap-2">
<Label htmlFor="wg-name">Name</Label>
<Input
id="wg-name"
value={wireGuardForm.name}
onChange={(e) => updateWireGuard("name", e.target.value)}
placeholder="e.g. Home WireGuard"
disabled={isSubmitting}
/>
</div>
{!editingVpn && (
<>
<div className="grid gap-2">
<Label htmlFor="wg-private-key">Private Key</Label>
<Input
id="wg-private-key"
value={wireGuardForm.privateKey}
onChange={(e) =>
updateWireGuard("privateKey", e.target.value)
}
placeholder="Base64-encoded private key"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="wg-address">Address</Label>
<Input
id="wg-address"
value={wireGuardForm.address}
onChange={(e) =>
updateWireGuard("address", e.target.value)
}
placeholder="e.g. 10.0.0.2/24"
disabled={isSubmitting}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="wg-dns">DNS (optional)</Label>
<Input
id="wg-dns"
value={wireGuardForm.dns}
onChange={(e) =>
updateWireGuard("dns", e.target.value)
}
placeholder="e.g. 1.1.1.1"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="wg-mtu">MTU (optional)</Label>
<Input
id="wg-mtu"
type="number"
value={wireGuardForm.mtu}
onChange={(e) =>
updateWireGuard("mtu", e.target.value)
}
placeholder="e.g. 1420"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="wg-peer-public-key">
Peer Public Key
</Label>
<Input
id="wg-peer-public-key"
value={wireGuardForm.peerPublicKey}
onChange={(e) =>
updateWireGuard("peerPublicKey", e.target.value)
}
placeholder="Base64-encoded peer public key"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="wg-peer-endpoint">Peer Endpoint</Label>
<Input
id="wg-peer-endpoint"
value={wireGuardForm.peerEndpoint}
onChange={(e) =>
updateWireGuard("peerEndpoint", e.target.value)
}
placeholder="e.g. vpn.example.com:51820"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="wg-allowed-ips">Allowed IPs</Label>
<Input
id="wg-allowed-ips"
value={wireGuardForm.allowedIps}
onChange={(e) =>
updateWireGuard("allowedIps", e.target.value)
}
placeholder="e.g. 0.0.0.0/0, ::/0"
disabled={isSubmitting}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="wg-keepalive">
Persistent Keepalive (optional)
</Label>
<Input
id="wg-keepalive"
type="number"
value={wireGuardForm.persistentKeepalive}
onChange={(e) =>
updateWireGuard(
"persistentKeepalive",
e.target.value,
)
}
placeholder="e.g. 25"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="wg-preshared-key">
Preshared Key (optional)
</Label>
<Input
id="wg-preshared-key"
value={wireGuardForm.presharedKey}
onChange={(e) =>
updateWireGuard("presharedKey", e.target.value)
}
placeholder="Base64-encoded preshared key"
disabled={isSubmitting}
/>
</div>
</div>
</>
)}
</>
)}
{vpnType === "OpenVPN" && (
<>
<div className="grid gap-2">
<Label htmlFor="ovpn-name">Name</Label>
<Input
id="ovpn-name"
value={openVpnForm.name}
onChange={(e) => updateOpenVpn("name", e.target.value)}
placeholder="e.g. Work OpenVPN"
disabled={isSubmitting}
/>
</div>
{!editingVpn && (
<div className="grid gap-2">
<Label htmlFor="ovpn-config">Raw Config</Label>
<Textarea
id="ovpn-config"
value={openVpnForm.rawConfig}
onChange={(e) =>
updateOpenVpn("rawConfig", e.target.value)
}
placeholder="Paste your .ovpn file content here..."
className="min-h-[200px] font-mono text-xs"
disabled={isSubmitting}
/>
</div>
)}
</>
)}
</div>
</ScrollArea>
<DialogFooter>
<RippleButton
variant="outline"
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</RippleButton>
<LoadingButton isLoading={isSubmitting} onClick={handleSubmit}>
{editingVpn ? "Update VPN" : "Create VPN"}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+354
View File
@@ -0,0 +1,354 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { LuShield, LuUpload } from "react-icons/lu";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RippleButton } from "@/components/ui/ripple";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getCurrentOS } from "@/lib/browser-utils";
import type { VpnImportResult, VpnType } from "@/types";
interface VpnImportDialogProps {
isOpen: boolean;
onClose: () => void;
}
type ImportStep = "dropzone" | "vpn-preview" | "vpn-result";
interface VpnPreviewData {
content: string;
filename: string;
detectedType: VpnType | null;
endpoint: string | null;
}
const detectVpnType = (
content: string,
filename: string,
): { isVpn: boolean; type: VpnType | null; endpoint: string | null } => {
const lowerFilename = filename.toLowerCase();
if (
lowerFilename.endsWith(".conf") &&
content.includes("[Interface]") &&
content.includes("[Peer]")
) {
const endpointMatch = content.match(/Endpoint\s*=\s*([^\s\n]+)/i);
return {
isVpn: true,
type: "WireGuard",
endpoint: endpointMatch ? endpointMatch[1] : null,
};
}
if (
lowerFilename.endsWith(".ovpn") ||
(content.includes("remote ") &&
(content.includes("client") || content.includes("dev tun")))
) {
const remoteMatch = content.match(/remote\s+(\S+)(?:\s+(\d+))?/i);
const endpoint = remoteMatch
? `${remoteMatch[1]}${remoteMatch[2] ? `:${remoteMatch[2]}` : ""}`
: null;
return { isVpn: true, type: "OpenVPN", endpoint };
}
return { isVpn: false, type: null, endpoint: null };
};
export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
const [step, setStep] = useState<ImportStep>("dropzone");
const [isDragOver, setIsDragOver] = useState(false);
const [vpnPreview, setVpnPreview] = useState<VpnPreviewData | null>(null);
const [vpnName, setVpnName] = useState("");
const [vpnImportResult, setVpnImportResult] =
useState<VpnImportResult | null>(null);
const [isImporting, setIsImporting] = useState(false);
const os = getCurrentOS();
const modKey = os === "macos" ? "⌘" : "Ctrl";
const resetState = useCallback(() => {
setStep("dropzone");
setIsDragOver(false);
setVpnPreview(null);
setVpnName("");
setVpnImportResult(null);
setIsImporting(false);
}, []);
const handleClose = useCallback(() => {
resetState();
onClose();
}, [resetState, onClose]);
const processContent = useCallback((content: string, filename: string) => {
const detection = detectVpnType(content, filename);
if (!detection.isVpn) {
toast.error("Content does not appear to be a valid VPN configuration");
return;
}
setVpnPreview({
content,
filename,
detectedType: detection.type,
endpoint: detection.endpoint,
});
const baseName = filename
.replace(/\.(conf|ovpn)$/i, "")
.replace(/_/g, " ")
.replace(/-/g, " ");
setVpnName(baseName || `${detection.type} VPN`);
setStep("vpn-preview");
}, []);
const handleFileRead = useCallback(
(file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
processContent(content, file.name);
};
reader.onerror = () => {
toast.error("Failed to read file");
};
reader.readAsText(file);
},
[processContent],
);
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
const validFile = files.find(
(f) => f.name.endsWith(".conf") || f.name.endsWith(".ovpn"),
);
if (validFile) {
handleFileRead(validFile);
} else {
toast.error("Please drop a .conf or .ovpn file");
}
},
[handleFileRead],
);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
}, []);
useEffect(() => {
if (!isOpen || step !== "dropzone") return;
const handlePaste = (e: ClipboardEvent) => {
const text = e.clipboardData?.getData("text");
if (text) {
processContent(text, "pasted.conf");
}
};
document.addEventListener("paste", handlePaste);
return () => {
document.removeEventListener("paste", handlePaste);
};
}, [isOpen, step, processContent]);
const handleImport = useCallback(async () => {
if (!vpnPreview) return;
setIsImporting(true);
try {
const result = await invoke<VpnImportResult>("import_vpn_config", {
content: vpnPreview.content,
filename: vpnPreview.filename,
name: vpnName.trim() || null,
});
setVpnImportResult(result);
setStep("vpn-result");
if (result.success) {
await emit("vpn-configs-changed");
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to import VPN config",
);
} finally {
setIsImporting(false);
}
}, [vpnPreview, vpnName]);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Import VPN Config</DialogTitle>
<DialogDescription>
{step === "dropzone" &&
"Import a WireGuard (.conf) or OpenVPN (.ovpn) configuration file"}
{step === "vpn-preview" && "Review the VPN configuration to import"}
{step === "vpn-result" && "VPN import completed"}
</DialogDescription>
</DialogHeader>
{step === "dropzone" && (
<div className="space-y-4">
<div
role="button"
tabIndex={0}
className={`
flex flex-col items-center justify-center
border-2 border-dashed rounded-lg p-8
transition-colors cursor-pointer
${isDragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"}
`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => document.getElementById("vpn-file-input")?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
document.getElementById("vpn-file-input")?.click();
}
}}
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
Drop a VPN config file here or click to browse
<br />
<span className="text-xs">
(.conf for WireGuard, .ovpn for OpenVPN)
</span>
</p>
<input
id="vpn-file-input"
type="file"
accept=".conf,.ovpn"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileRead(file);
e.target.value = "";
}}
/>
</div>
<p className="text-xs text-muted-foreground text-center">
Paste from clipboard with {modKey}+V
</p>
</div>
)}
{step === "vpn-preview" && vpnPreview && (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
<LuShield className="w-8 h-8 text-primary" />
<div>
<div className="font-medium">
{vpnPreview.detectedType} Configuration
</div>
{vpnPreview.endpoint && (
<div className="text-sm text-muted-foreground">
Endpoint: {vpnPreview.endpoint}
</div>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="vpn-name">VPN Name</Label>
<Input
id="vpn-name"
placeholder="My VPN"
value={vpnName}
onChange={(e) => setVpnName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Config Preview</Label>
<ScrollArea className="h-[150px] border rounded-md">
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all">
{vpnPreview.content.slice(0, 1000)}
{vpnPreview.content.length > 1000 && "..."}
</pre>
</ScrollArea>
</div>
</div>
)}
{step === "vpn-result" && vpnImportResult && (
<div className="space-y-4">
<div
className={`p-4 rounded-lg ${vpnImportResult.success ? "bg-green-500/10" : "bg-red-500/10"}`}
>
{vpnImportResult.success ? (
<div className="flex items-center gap-3">
<LuShield className="w-8 h-8 text-green-600 dark:text-green-400" />
<div>
<div className="font-medium text-green-600 dark:text-green-400">
VPN Imported Successfully
</div>
<div className="text-sm text-muted-foreground">
{vpnImportResult.name} ({vpnImportResult.vpn_type})
</div>
</div>
</div>
) : (
<div className="space-y-2">
<div className="font-medium text-red-600 dark:text-red-400">
Import Failed
</div>
<div className="text-sm text-red-600 dark:text-red-400">
{vpnImportResult.error}
</div>
</div>
)}
</div>
</div>
)}
<DialogFooter>
{step === "dropzone" && (
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</RippleButton>
)}
{step === "vpn-preview" && (
<>
<RippleButton variant="outline" onClick={resetState}>
Back
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => void handleImport()}
>
Import VPN
</LoadingButton>
</>
)}
{step === "vpn-result" && (
<RippleButton onClick={handleClose}>Done</RippleButton>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+34 -23
View File
@@ -2,7 +2,7 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { AppUpdateToast } from "@/components/app-update-toast";
import { showToast } from "@/lib/toast-utils";
@@ -16,6 +16,7 @@ export function useAppUpdateNotifications() {
const [updateReady, setUpdateReady] = useState(false);
const [isClient, setIsClient] = useState(false);
const [dismissedVersion, setDismissedVersion] = useState<string | null>(null);
const autoDownloadedVersion = useRef<string | null>(null);
// Ensure we're on the client side to prevent hydration mismatches
useEffect(() => {
@@ -52,6 +53,7 @@ export function useAppUpdateNotifications() {
console.log("Manual check result:", update);
// Always show manual check results, even if previously dismissed
autoDownloadedVersion.current = null;
setUpdateInfo(update);
} catch (error) {
console.error("Failed to manually check for app updates:", error);
@@ -112,7 +114,7 @@ export function useAppUpdateNotifications() {
toast.dismiss("app-update");
}, [isClient, updateInfo]);
// Listen for app update availability
// Listen for app update events
useEffect(() => {
if (!isClient) return;
@@ -127,16 +129,7 @@ export function useAppUpdateNotifications() {
const unlistenProgress = listen<AppUpdateProgress>(
"app-update-progress",
(event) => {
console.log("App update progress:", event.payload);
setUpdateProgress(event.payload);
// If update is completed, mark as no longer updating after a delay
if (event.payload.stage === "completed") {
setTimeout(() => {
setIsUpdating(false);
setUpdateProgress(null);
}, 5000); // Show completion message for 5 seconds instead of 2
}
},
);
@@ -160,41 +153,59 @@ export function useAppUpdateNotifications() {
};
}, [isClient]);
// Show toast when update is available
// Auto-download update in background when found
useEffect(() => {
if (!isClient || !updateInfo) return;
if (
!isClient ||
!updateInfo ||
updateInfo.manual_update_required ||
isUpdating ||
updateReady ||
autoDownloadedVersion.current === updateInfo.new_version
)
return;
autoDownloadedVersion.current = updateInfo.new_version;
console.log("Auto-downloading app update:", updateInfo.new_version);
void handleAppUpdate(updateInfo);
}, [isClient, updateInfo, isUpdating, updateReady, handleAppUpdate]);
// Show toast only when update is ready to install or requires manual action
useEffect(() => {
if (!isClient) return;
const showManualToast = updateInfo?.manual_update_required && !isUpdating;
if (!updateReady && !showManualToast) {
return;
}
if (!updateInfo) return;
toast.custom(
() => (
<AppUpdateToast
updateInfo={updateInfo}
onUpdate={handleAppUpdate}
onRestart={handleRestart}
onDismiss={dismissAppUpdate}
isUpdating={isUpdating}
updateProgress={updateProgress}
updateReady={updateReady}
/>
),
{
id: "app-update",
duration: Number.POSITIVE_INFINITY, // Persistent until user action
duration: Number.POSITIVE_INFINITY,
position: "top-left",
style: {
zIndex: 99999, // Ensure app updates appear above dialogs
pointerEvents: "auto", // Ensure app updates remain interactive
marginTop: "16px", // slightly lower on macOS-like top controls
zIndex: 99999,
pointerEvents: "auto",
marginTop: "16px",
},
},
);
}, [
updateInfo,
handleAppUpdate,
handleRestart,
dismissAppUpdate,
isUpdating,
updateProgress,
updateReady,
isUpdating,
isClient,
]);
+21
View File
@@ -273,6 +273,17 @@ export function useBrowserDownload() {
const progress = event.payload;
setDownloadProgress(progress);
if (
progress.stage === "downloading" ||
progress.stage === "extracting" ||
progress.stage === "verifying"
) {
setDownloadingBrowsers((prev) => {
if (prev.has(progress.browser)) return prev;
return new Set(prev).add(progress.browser);
});
}
const browserName = getBrowserDisplayName(progress.browser);
if (progress.stage === "downloading") {
@@ -311,11 +322,21 @@ export function useBrowserDownload() {
} else if (progress.stage === "verifying") {
showDownloadToast(browserName, progress.version, "verifying");
} else if (progress.stage === "cancelled") {
setDownloadingBrowsers((prev) => {
const next = new Set(prev);
next.delete(progress.browser);
return next;
});
dismissToast(
`download-${browserName.toLowerCase()}-${progress.version}`,
);
setDownloadProgress(null);
} else if (progress.stage === "completed") {
setDownloadingBrowsers((prev) => {
const next = new Set(prev);
next.delete(progress.browser);
return next;
});
// On completion, refresh the downloaded versions for this browser and also refresh camoufox,
// since the Create dialog implicitly uses camoufox on the anti-detect tab
try {
+12 -1
View File
@@ -1,5 +1,9 @@
import { useCallback, useEffect, useState } from "react";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import {
getBrowserDisplayName,
getOSDisplayName,
isCrossOsProfile,
} from "@/lib/browser-utils";
import type { BrowserProfile } from "@/types";
/**
@@ -48,6 +52,8 @@ export function useBrowserState(
(profile: BrowserProfile): boolean => {
if (!isClient) return false;
if (isCrossOsProfile(profile)) return false;
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
@@ -166,6 +172,11 @@ export function useBrowserState(
(profile: BrowserProfile): string => {
if (!isClient) return "Loading...";
if (isCrossOsProfile(profile) && profile.host_os) {
const osName = getOSDisplayName(profile.host_os);
return `This profile was created on ${osName} and is not supported on this system`;
}
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
+87
View File
@@ -0,0 +1,87 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import type { VpnConfig } from "@/types";
/**
* Custom hook to manage VPN-related state and listen for backend events.
* This hook eliminates the need for manual UI refreshes by automatically
* updating state when the backend emits VPN change events.
*/
export function useVpnEvents() {
const [vpnConfigs, setVpnConfigs] = useState<VpnConfig[]>([]);
const [vpnUsage, setVpnUsage] = useState<Record<string, number>>({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadVpnUsage = useCallback(async () => {
try {
const profiles = await invoke<Array<{ vpn_id?: string }>>(
"list_browser_profiles",
);
const counts: Record<string, number> = {};
for (const p of profiles) {
if (p.vpn_id) counts[p.vpn_id] = (counts[p.vpn_id] ?? 0) + 1;
}
setVpnUsage(counts);
} catch (err) {
console.error("Failed to load VPN usage:", err);
}
}, []);
const loadVpnConfigs = useCallback(async () => {
try {
const configs = await invoke<VpnConfig[]>("list_vpn_configs");
setVpnConfigs(configs);
await loadVpnUsage();
setError(null);
} catch (err: unknown) {
console.error("Failed to load VPN configs:", err);
setError(`Failed to load VPN configs: ${JSON.stringify(err)}`);
}
}, [loadVpnUsage]);
const clearError = useCallback(() => {
setError(null);
}, []);
useEffect(() => {
let vpnConfigsUnlisten: (() => void) | undefined;
let profilesUnlisten: (() => void) | undefined;
const setupListeners = async () => {
try {
await loadVpnConfigs();
vpnConfigsUnlisten = await listen("vpn-configs-changed", () => {
void loadVpnConfigs();
});
profilesUnlisten = await listen("profiles-changed", () => {
void loadVpnUsage();
});
} catch (err) {
console.error("Failed to setup VPN event listeners:", err);
setError(`Failed to setup VPN event listeners: ${JSON.stringify(err)}`);
} finally {
setIsLoading(false);
}
};
void setupListeners();
return () => {
if (vpnConfigsUnlisten) vpnConfigsUnlisten();
if (profilesUnlisten) profilesUnlisten();
};
}, [loadVpnConfigs, loadVpnUsage]);
return {
vpnConfigs,
vpnUsage,
isLoading,
error,
loadVpnConfigs,
clearError,
};
}
+31 -9
View File
@@ -126,7 +126,7 @@
"createProfile": "Create a new profile",
"menu": {
"settings": "Settings",
"proxies": "Proxies",
"proxies": "Proxies & VPNs",
"groups": "Groups",
"syncService": "Account",
"integrations": "Integrations",
@@ -147,7 +147,7 @@
"actions": "Actions",
"note": "Note",
"group": "Group",
"proxy": "Proxy",
"proxy": "Proxy / VPN",
"lastLaunch": "Last Launch"
},
"actions": {
@@ -166,8 +166,8 @@
"antiDetect": {
"title": "Anti-Detect Browser",
"description": "Choose a browser with anti-detection capabilities",
"chromium": "Chromium (Wayfern)",
"firefox": "Firefox (Camoufox)",
"chromium": "Wayfern",
"firefox": "Camoufox",
"badge": "Anti-Detect Browser"
},
"regular": {
@@ -178,10 +178,10 @@
"profileName": "Profile Name",
"profileNamePlaceholder": "Enter profile name",
"proxy": {
"title": "Proxy",
"title": "Proxy / VPN",
"addProxy": "Add Proxy",
"noProxy": "No proxy",
"noProxiesAvailable": "No proxies available. Add one to route this profile's traffic."
"noProxy": "No proxy / VPN",
"noProxiesAvailable": "No proxies or VPNs available. Add one to route this profile's traffic."
},
"version": {
"fetching": "Fetching available versions...",
@@ -203,7 +203,7 @@
},
"proxies": {
"title": "Proxies",
"management": "Proxy Management",
"management": "Proxies & VPNs",
"add": "Add Proxy",
"edit": "Edit Proxy",
"delete": "Delete Proxy",
@@ -429,6 +429,7 @@
"importSuccess": "Successfully imported {{count}} items",
"exportSuccess": "Successfully exported {{count}} items",
"syncSuccess": "Sync completed successfully",
"profileSynced": "Profile '{{name}}' synced successfully",
"cacheCleared": "Cache cleared successfully"
},
"error": {
@@ -448,6 +449,7 @@
"importFailed": "Failed to import",
"exportFailed": "Failed to export",
"syncFailed": "Sync failed",
"profileSyncFailed": "Failed to sync profile '{{name}}'",
"cacheClearFailed": "Failed to clear cache",
"unknown": "An unknown error occurred"
},
@@ -456,6 +458,7 @@
"extracting": "Extracting {{browser}} {{version}}",
"verifying": "Verifying {{browser}} {{version}}",
"syncing": "Syncing...",
"syncingProfile": "Syncing profile '{{name}}'...",
"updatingVersions": "Updating browser versions..."
}
},
@@ -480,6 +483,25 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "Spoofing a different operating system is harder — system-level APIs are more difficult to mask, making it easier for websites to detect inconsistencies. No anti-detect browser can perfectly spoof every detail across operating systems."
"crossOsWarning": "Spoofing fingerprint for a different operating system is less reliable because it is impossible to perfectly mimic all underlying components. Use with caution."
},
"syncAll": {
"title": "Enable Sync for Existing Items",
"description": "You have items that are not being synced. Would you like to enable sync for all of them?",
"itemsList": "Items not synced: {{items}}",
"proxies": "{{count}} proxy",
"proxies_plural": "{{count}} proxies",
"groups": "{{count}} group",
"groups_plural": "{{count}} groups",
"vpns": "{{count}} VPN",
"vpns_plural": "{{count}} VPNs",
"enableAll": "Enable All",
"skip": "Skip",
"success": "Sync enabled for all items"
},
"crossOs": {
"viewOnly": "This profile was created on {{os}} and is not supported on this system",
"cannotLaunch": "This profile was created on {{os}} and is not supported on this system",
"cannotModify": "Cannot modify sync settings for a cross-OS profile"
}
}
+31 -9
View File
@@ -126,7 +126,7 @@
"createProfile": "Crear un nuevo perfil",
"menu": {
"settings": "Configuración",
"proxies": "Proxies",
"proxies": "Proxies y VPNs",
"groups": "Grupos",
"syncService": "Cuenta",
"integrations": "Integraciones",
@@ -147,7 +147,7 @@
"actions": "Acciones",
"note": "Nota",
"group": "Grupo",
"proxy": "Proxy",
"proxy": "Proxy / VPN",
"lastLaunch": "Último Inicio"
},
"actions": {
@@ -166,8 +166,8 @@
"antiDetect": {
"title": "Navegador Anti-Detección",
"description": "Elige un navegador con capacidades anti-detección",
"chromium": "Chromium (Wayfern)",
"firefox": "Firefox (Camoufox)",
"chromium": "Wayfern",
"firefox": "Camoufox",
"badge": "Navegador Anti-Detección"
},
"regular": {
@@ -178,10 +178,10 @@
"profileName": "Nombre del Perfil",
"profileNamePlaceholder": "Ingresa el nombre del perfil",
"proxy": {
"title": "Proxy",
"title": "Proxy / VPN",
"addProxy": "Agregar Proxy",
"noProxy": "Sin proxy",
"noProxiesAvailable": "No hay proxies disponibles. Agrega uno para enrutar el tráfico de este perfil."
"noProxy": "Sin proxy / VPN",
"noProxiesAvailable": "No hay proxies o VPNs disponibles. Agrega uno para enrutar el tráfico de este perfil."
},
"version": {
"fetching": "Obteniendo versiones disponibles...",
@@ -203,7 +203,7 @@
},
"proxies": {
"title": "Proxies",
"management": "Gestión de Proxies",
"management": "Proxies y VPNs",
"add": "Agregar Proxy",
"edit": "Editar Proxy",
"delete": "Eliminar Proxy",
@@ -429,6 +429,7 @@
"importSuccess": "{{count}} elementos importados exitosamente",
"exportSuccess": "{{count}} elementos exportados exitosamente",
"syncSuccess": "Sincronización completada exitosamente",
"profileSynced": "Perfil '{{name}}' sincronizado exitosamente",
"cacheCleared": "Caché limpiada exitosamente"
},
"error": {
@@ -448,6 +449,7 @@
"importFailed": "Error al importar",
"exportFailed": "Error al exportar",
"syncFailed": "Error de sincronización",
"profileSyncFailed": "Error al sincronizar perfil '{{name}}'",
"cacheClearFailed": "Error al limpiar caché",
"unknown": "Ocurrió un error desconocido"
},
@@ -456,6 +458,7 @@
"extracting": "Extrayendo {{browser}} {{version}}",
"verifying": "Verificando {{browser}} {{version}}",
"syncing": "Sincronizando...",
"syncingProfile": "Sincronizando perfil '{{name}}'...",
"updatingVersions": "Actualizando versiones de navegadores..."
}
},
@@ -480,6 +483,25 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "Suplantar un sistema operativo diferente es más difícil: las API a nivel de sistema son más difíciles de enmascarar, lo que facilita que los sitios web detecten inconsistencias. Ningún navegador antidetección puede suplantar perfectamente cada detalle entre sistemas operativos."
"crossOsWarning": "La suplantación de huella digital para un sistema operativo diferente es menos fiable porque es imposible imitar perfectamente todos los componentes subyacentes. Usar con precaución."
},
"syncAll": {
"title": "Activar sincronización para elementos existentes",
"description": "Tienes elementos que no se están sincronizando. ¿Te gustaría activar la sincronización para todos?",
"itemsList": "Elementos no sincronizados: {{items}}",
"proxies": "{{count}} proxy",
"proxies_plural": "{{count}} proxies",
"groups": "{{count}} grupo",
"groups_plural": "{{count}} grupos",
"vpns": "{{count}} VPN",
"vpns_plural": "{{count}} VPNs",
"enableAll": "Activar todos",
"skip": "Omitir",
"success": "Sincronización activada para todos los elementos"
},
"crossOs": {
"viewOnly": "Este perfil fue creado en {{os}} y no es compatible con este sistema",
"cannotLaunch": "Este perfil fue creado en {{os}} y no es compatible con este sistema",
"cannotModify": "No se pueden modificar los ajustes de sincronización de un perfil de otro sistema operativo"
}
}
+31 -9
View File
@@ -126,7 +126,7 @@
"createProfile": "Créer un nouveau profil",
"menu": {
"settings": "Paramètres",
"proxies": "Proxies",
"proxies": "Proxys et VPNs",
"groups": "Groupes",
"syncService": "Compte",
"integrations": "Intégrations",
@@ -147,7 +147,7 @@
"actions": "Actions",
"note": "Note",
"group": "Groupe",
"proxy": "Proxy",
"proxy": "Proxy / VPN",
"lastLaunch": "Dernier lancement"
},
"actions": {
@@ -166,8 +166,8 @@
"antiDetect": {
"title": "Navigateur anti-détection",
"description": "Choisissez un navigateur avec des capacités anti-détection",
"chromium": "Chromium (Wayfern)",
"firefox": "Firefox (Camoufox)",
"chromium": "Wayfern",
"firefox": "Camoufox",
"badge": "Navigateur anti-détection"
},
"regular": {
@@ -178,10 +178,10 @@
"profileName": "Nom du profil",
"profileNamePlaceholder": "Entrez le nom du profil",
"proxy": {
"title": "Proxy",
"title": "Proxy / VPN",
"addProxy": "Ajouter un proxy",
"noProxy": "Pas de proxy",
"noProxiesAvailable": "Aucun proxy disponible. Ajoutez-en un pour acheminer le trafic de ce profil."
"noProxy": "Pas de proxy / VPN",
"noProxiesAvailable": "Aucun proxy ou VPN disponible. Ajoutez-en un pour router le trafic de ce profil."
},
"version": {
"fetching": "Récupération des versions disponibles...",
@@ -203,7 +203,7 @@
},
"proxies": {
"title": "Proxies",
"management": "Gestion des proxies",
"management": "Proxys et VPNs",
"add": "Ajouter un proxy",
"edit": "Modifier le proxy",
"delete": "Supprimer le proxy",
@@ -429,6 +429,7 @@
"importSuccess": "{{count}} éléments importés avec succès",
"exportSuccess": "{{count}} éléments exportés avec succès",
"syncSuccess": "Synchronisation terminée avec succès",
"profileSynced": "Profil '{{name}}' synchronisé avec succès",
"cacheCleared": "Cache effacé avec succès"
},
"error": {
@@ -448,6 +449,7 @@
"importFailed": "Échec de l'importation",
"exportFailed": "Échec de l'exportation",
"syncFailed": "Échec de la synchronisation",
"profileSyncFailed": "Échec de la synchronisation du profil '{{name}}'",
"cacheClearFailed": "Échec de l'effacement du cache",
"unknown": "Une erreur inconnue s'est produite"
},
@@ -456,6 +458,7 @@
"extracting": "Extraction de {{browser}} {{version}}",
"verifying": "Vérification de {{browser}} {{version}}",
"syncing": "Synchronisation...",
"syncingProfile": "Synchronisation du profil '{{name}}'...",
"updatingVersions": "Mise à jour des versions de navigateurs..."
}
},
@@ -480,6 +483,25 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "Usurper un système d'exploitation différent est plus difficile : les API au niveau du système sont plus difficiles à masquer, ce qui permet aux sites web de détecter plus facilement les incohérences. Aucun navigateur anti-détection ne peut parfaitement usurper chaque détail d'un système d'exploitation à l'autre."
"crossOsWarning": "L'usurpation d'empreinte pour un système d'exploitation différent est moins fiable car il est impossible d'imiter parfaitement tous les composants sous-jacents. À utiliser avec précaution."
},
"syncAll": {
"title": "Activer la synchronisation pour les éléments existants",
"description": "Vous avez des éléments qui ne sont pas synchronisés. Voulez-vous activer la synchronisation pour tous ?",
"itemsList": "Éléments non synchronisés : {{items}}",
"proxies": "{{count}} proxy",
"proxies_plural": "{{count}} proxies",
"groups": "{{count}} groupe",
"groups_plural": "{{count}} groupes",
"vpns": "{{count}} VPN",
"vpns_plural": "{{count}} VPNs",
"enableAll": "Tout activer",
"skip": "Ignorer",
"success": "Synchronisation activée pour tous les éléments"
},
"crossOs": {
"viewOnly": "Ce profil a été créé sur {{os}} et n'est pas pris en charge sur ce système",
"cannotLaunch": "Ce profil a été créé sur {{os}} et n'est pas pris en charge sur ce système",
"cannotModify": "Impossible de modifier les paramètres de synchronisation d'un profil d'un autre système d'exploitation"
}
}
+31 -9
View File
@@ -126,7 +126,7 @@
"createProfile": "新しいプロファイルを作成",
"menu": {
"settings": "設定",
"proxies": "プロキシ",
"proxies": "プロキシ & VPN",
"groups": "グループ",
"syncService": "アカウント",
"integrations": "統合",
@@ -147,7 +147,7 @@
"actions": "アクション",
"note": "メモ",
"group": "グループ",
"proxy": "プロキシ",
"proxy": "プロキシ / VPN",
"lastLaunch": "最終起動"
},
"actions": {
@@ -166,8 +166,8 @@
"antiDetect": {
"title": "アンチ検出ブラウザ",
"description": "アンチ検出機能を持つブラウザを選択",
"chromium": "Chromium (Wayfern)",
"firefox": "Firefox (Camoufox)",
"chromium": "Wayfern",
"firefox": "Camoufox",
"badge": "アンチ検出ブラウザ"
},
"regular": {
@@ -178,10 +178,10 @@
"profileName": "プロファイル名",
"profileNamePlaceholder": "プロファイル名を入力",
"proxy": {
"title": "プロキシ",
"title": "プロキシ / VPN",
"addProxy": "プロキシを追加",
"noProxy": "プロキシなし",
"noProxiesAvailable": "利用可能なプロキシがありません。このプロファイルのトラフィックをルーティングするためにプロキシを追加してください。"
"noProxy": "プロキシ / VPNなし",
"noProxiesAvailable": "利用可能なプロキシまたはVPNがありません。このプロファイルのトラフィックをルーティングするために追加してください。"
},
"version": {
"fetching": "利用可能なバージョンを取得中...",
@@ -203,7 +203,7 @@
},
"proxies": {
"title": "プロキシ",
"management": "プロキシ管理",
"management": "プロキシ & VPN",
"add": "プロキシを追加",
"edit": "プロキシを編集",
"delete": "プロキシを削除",
@@ -429,6 +429,7 @@
"importSuccess": "{{count}} 個のアイテムが正常にインポートされました",
"exportSuccess": "{{count}} 個のアイテムが正常にエクスポートされました",
"syncSuccess": "同期が正常に完了しました",
"profileSynced": "プロファイル '{{name}}' が正常に同期されました",
"cacheCleared": "キャッシュが正常にクリアされました"
},
"error": {
@@ -448,6 +449,7 @@
"importFailed": "インポートに失敗しました",
"exportFailed": "エクスポートに失敗しました",
"syncFailed": "同期に失敗しました",
"profileSyncFailed": "プロファイル '{{name}}' の同期に失敗しました",
"cacheClearFailed": "キャッシュのクリアに失敗しました",
"unknown": "不明なエラーが発生しました"
},
@@ -456,6 +458,7 @@
"extracting": "{{browser}} {{version}} を展開中",
"verifying": "{{browser}} {{version}} を確認中",
"syncing": "同期中...",
"syncingProfile": "プロファイル '{{name}}' を同期中...",
"updatingVersions": "ブラウザバージョンを更新中..."
}
},
@@ -480,6 +483,25 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "異なるオペレーティングシステムの偽装はより困難です。システムレベルのAPIはマスクしにくく、ウェブサイトが矛盾を検出しやすくなります。どのアンチディテクトブラウザも、異なるOS間のすべての詳細を完璧に偽装することはできません。"
"crossOsWarning": "異なるオペレーティングシステムのフィンガープリント偽装は、すべての基盤コンポーネントを完璧に模倣することが不可能なため、信頼性が低くなります。注意してご使用ください。"
},
"syncAll": {
"title": "既存アイテムの同期を有効にする",
"description": "同期されていないアイテムがあります。すべての同期を有効にしますか?",
"itemsList": "未同期アイテム: {{items}}",
"proxies": "{{count}}個のプロキシ",
"proxies_plural": "{{count}}個のプロキシ",
"groups": "{{count}}個のグループ",
"groups_plural": "{{count}}個のグループ",
"vpns": "{{count}}個のVPN",
"vpns_plural": "{{count}}個のVPN",
"enableAll": "すべて有効にする",
"skip": "スキップ",
"success": "すべてのアイテムの同期が有効になりました"
},
"crossOs": {
"viewOnly": "このプロファイルは{{os}}で作成されたもので、このシステムではサポートされていません",
"cannotLaunch": "このプロファイルは{{os}}で作成されたもので、このシステムではサポートされていません",
"cannotModify": "他のOSのプロファイルの同期設定は変更できません"
}
}
+31 -9
View File
@@ -126,7 +126,7 @@
"createProfile": "Criar um novo perfil",
"menu": {
"settings": "Configurações",
"proxies": "Proxies",
"proxies": "Proxies e VPNs",
"groups": "Grupos",
"syncService": "Conta",
"integrations": "Integrações",
@@ -147,7 +147,7 @@
"actions": "Ações",
"note": "Nota",
"group": "Grupo",
"proxy": "Proxy",
"proxy": "Proxy / VPN",
"lastLaunch": "Último Início"
},
"actions": {
@@ -166,8 +166,8 @@
"antiDetect": {
"title": "Navegador Anti-Detecção",
"description": "Escolha um navegador com capacidades anti-detecção",
"chromium": "Chromium (Wayfern)",
"firefox": "Firefox (Camoufox)",
"chromium": "Wayfern",
"firefox": "Camoufox",
"badge": "Navegador Anti-Detecção"
},
"regular": {
@@ -178,10 +178,10 @@
"profileName": "Nome do Perfil",
"profileNamePlaceholder": "Digite o nome do perfil",
"proxy": {
"title": "Proxy",
"title": "Proxy / VPN",
"addProxy": "Adicionar Proxy",
"noProxy": "Sem proxy",
"noProxiesAvailable": "Nenhum proxy disponível. Adicione um para rotear o tráfego deste perfil."
"noProxy": "Sem proxy / VPN",
"noProxiesAvailable": "Nenhum proxy ou VPN disponível. Adicione um para rotear o tráfego deste perfil."
},
"version": {
"fetching": "Buscando versões disponíveis...",
@@ -203,7 +203,7 @@
},
"proxies": {
"title": "Proxies",
"management": "Gerenciamento de Proxies",
"management": "Proxies e VPNs",
"add": "Adicionar Proxy",
"edit": "Editar Proxy",
"delete": "Excluir Proxy",
@@ -429,6 +429,7 @@
"importSuccess": "{{count}} itens importados com sucesso",
"exportSuccess": "{{count}} itens exportados com sucesso",
"syncSuccess": "Sincronização concluída com sucesso",
"profileSynced": "Perfil '{{name}}' sincronizado com sucesso",
"cacheCleared": "Cache limpo com sucesso"
},
"error": {
@@ -448,6 +449,7 @@
"importFailed": "Falha ao importar",
"exportFailed": "Falha ao exportar",
"syncFailed": "Falha na sincronização",
"profileSyncFailed": "Falha ao sincronizar perfil '{{name}}'",
"cacheClearFailed": "Falha ao limpar cache",
"unknown": "Ocorreu um erro desconhecido"
},
@@ -456,6 +458,7 @@
"extracting": "Extraindo {{browser}} {{version}}",
"verifying": "Verificando {{browser}} {{version}}",
"syncing": "Sincronizando...",
"syncingProfile": "Sincronizando perfil '{{name}}'...",
"updatingVersions": "Atualizando versões de navegadores..."
}
},
@@ -480,6 +483,25 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "Falsificar um sistema operacional diferente é mais difícil: as APIs de nível de sistema são mais difíceis de mascarar, facilitando a detecção de inconsistências pelos sites. Nenhum navegador antidetecção consegue falsificar perfeitamente todos os detalhes entre sistemas operacionais."
"crossOsWarning": "A falsificação de impressão digital para um sistema operacional diferente é menos confiável porque é impossível imitar perfeitamente todos os componentes subjacentes. Use com cautela."
},
"syncAll": {
"title": "Ativar sincronização para itens existentes",
"description": "Você tem itens que não estão sendo sincronizados. Gostaria de ativar a sincronização para todos?",
"itemsList": "Itens não sincronizados: {{items}}",
"proxies": "{{count}} proxy",
"proxies_plural": "{{count}} proxies",
"groups": "{{count}} grupo",
"groups_plural": "{{count}} grupos",
"vpns": "{{count}} VPN",
"vpns_plural": "{{count}} VPNs",
"enableAll": "Ativar todos",
"skip": "Pular",
"success": "Sincronização ativada para todos os itens"
},
"crossOs": {
"viewOnly": "Este perfil foi criado em {{os}} e não é compatível com este sistema",
"cannotLaunch": "Este perfil foi criado em {{os}} e não é compatível com este sistema",
"cannotModify": "Não é possível modificar as configurações de sincronização de um perfil de outro sistema operacional"
}
}
+31 -9
View File
@@ -126,7 +126,7 @@
"createProfile": "Создать новый профиль",
"menu": {
"settings": "Настройки",
"proxies": "Прокси",
"proxies": "Прокси и VPN",
"groups": "Группы",
"syncService": "Аккаунт",
"integrations": "Интеграции",
@@ -147,7 +147,7 @@
"actions": "Действия",
"note": "Заметка",
"group": "Группа",
"proxy": "Прокси",
"proxy": "Прокси / VPN",
"lastLaunch": "Последний запуск"
},
"actions": {
@@ -166,8 +166,8 @@
"antiDetect": {
"title": "Антидетект браузер",
"description": "Выберите браузер с возможностями защиты от обнаружения",
"chromium": "Chromium (Wayfern)",
"firefox": "Firefox (Camoufox)",
"chromium": "Wayfern",
"firefox": "Camoufox",
"badge": "Антидетект браузер"
},
"regular": {
@@ -178,10 +178,10 @@
"profileName": "Название профиля",
"profileNamePlaceholder": "Введите название профиля",
"proxy": {
"title": "Прокси",
"title": "Прокси / VPN",
"addProxy": "Добавить прокси",
"noProxy": "Без прокси",
"noProxiesAvailable": "Нет доступных прокси. Добавьте один для маршрутизации трафика этого профиля."
"noProxy": "Без прокси / VPN",
"noProxiesAvailable": "Нет доступных прокси или VPN. Добавьте один для маршрутизации трафика этого профиля."
},
"version": {
"fetching": "Получение доступных версий...",
@@ -203,7 +203,7 @@
},
"proxies": {
"title": "Прокси",
"management": "Управление прокси",
"management": "Прокси и VPN",
"add": "Добавить прокси",
"edit": "Редактировать прокси",
"delete": "Удалить прокси",
@@ -429,6 +429,7 @@
"importSuccess": "Успешно импортировано {{count}} элементов",
"exportSuccess": "Успешно экспортировано {{count}} элементов",
"syncSuccess": "Синхронизация успешно завершена",
"profileSynced": "Профиль '{{name}}' успешно синхронизирован",
"cacheCleared": "Кэш успешно очищен"
},
"error": {
@@ -448,6 +449,7 @@
"importFailed": "Ошибка импорта",
"exportFailed": "Ошибка экспорта",
"syncFailed": "Ошибка синхронизации",
"profileSyncFailed": "Ошибка синхронизации профиля '{{name}}'",
"cacheClearFailed": "Ошибка очистки кэша",
"unknown": "Произошла неизвестная ошибка"
},
@@ -456,6 +458,7 @@
"extracting": "Распаковка {{browser}} {{version}}",
"verifying": "Проверка {{browser}} {{version}}",
"syncing": "Синхронизация...",
"syncingProfile": "Синхронизация профиля '{{name}}'...",
"updatingVersions": "Обновление версий браузеров..."
}
},
@@ -480,6 +483,25 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "Подмена другой операционной системы сложнее — системные API труднее замаскировать, что упрощает обнаружение несоответствий веб-сайтами. Ни один антидетект-браузер не может идеально подменить все детали при смене операционной системы."
"crossOsWarning": "Подмена отпечатка для другой операционной системы менее надёжна, так как невозможно идеально имитировать все базовые компоненты. Используйте с осторожностью."
},
"syncAll": {
"title": "Включить синхронизацию для существующих элементов",
"description": "У вас есть элементы, которые не синхронизируются. Хотите включить синхронизацию для всех?",
"itemsList": "Несинхронизированные элементы: {{items}}",
"proxies": "{{count}} прокси",
"proxies_plural": "{{count}} прокси",
"groups": "{{count}} группа",
"groups_plural": "{{count}} групп",
"vpns": "{{count}} VPN",
"vpns_plural": "{{count}} VPN",
"enableAll": "Включить все",
"skip": "Пропустить",
"success": "Синхронизация включена для всех элементов"
},
"crossOs": {
"viewOnly": "Этот профиль был создан на {{os}} и не поддерживается в этой системе",
"cannotLaunch": "Этот профиль был создан на {{os}} и не поддерживается в этой системе",
"cannotModify": "Невозможно изменить настройки синхронизации профиля другой ОС"
}
}
+31 -9
View File
@@ -126,7 +126,7 @@
"createProfile": "创建新配置文件",
"menu": {
"settings": "设置",
"proxies": "代理",
"proxies": "代理和VPN",
"groups": "分组",
"syncService": "账户",
"integrations": "集成",
@@ -147,7 +147,7 @@
"actions": "操作",
"note": "备注",
"group": "分组",
"proxy": "代理",
"proxy": "代理 / VPN",
"lastLaunch": "最后启动"
},
"actions": {
@@ -166,8 +166,8 @@
"antiDetect": {
"title": "防检测浏览器",
"description": "选择具有防检测功能的浏览器",
"chromium": "Chromium (Wayfern)",
"firefox": "Firefox (Camoufox)",
"chromium": "Wayfern",
"firefox": "Camoufox",
"badge": "防检测浏览器"
},
"regular": {
@@ -178,10 +178,10 @@
"profileName": "配置文件名称",
"profileNamePlaceholder": "输入配置文件名称",
"proxy": {
"title": "代理",
"title": "代理 / VPN",
"addProxy": "添加代理",
"noProxy": "无代理",
"noProxiesAvailable": "没有可用的代理。添加一个代理来路由此配置文件的流量。"
"noProxy": "无代理 / VPN",
"noProxiesAvailable": "没有可用的代理或VPN。添加一个来路由此配置文件的流量。"
},
"version": {
"fetching": "正在获取可用版本...",
@@ -203,7 +203,7 @@
},
"proxies": {
"title": "代理",
"management": "代理管理",
"management": "代理和VPN",
"add": "添加代理",
"edit": "编辑代理",
"delete": "删除代理",
@@ -429,6 +429,7 @@
"importSuccess": "成功导入 {{count}} 个项目",
"exportSuccess": "成功导出 {{count}} 个项目",
"syncSuccess": "同步成功完成",
"profileSynced": "配置文件 '{{name}}' 同步成功",
"cacheCleared": "缓存清除成功"
},
"error": {
@@ -448,6 +449,7 @@
"importFailed": "导入失败",
"exportFailed": "导出失败",
"syncFailed": "同步失败",
"profileSyncFailed": "配置文件 '{{name}}' 同步失败",
"cacheClearFailed": "清除缓存失败",
"unknown": "发生未知错误"
},
@@ -456,6 +458,7 @@
"extracting": "正在解压 {{browser}} {{version}}",
"verifying": "正在验证 {{browser}} {{version}}",
"syncing": "同步中...",
"syncingProfile": "正在同步配置文件 '{{name}}'...",
"updatingVersions": "正在更新浏览器版本..."
}
},
@@ -480,6 +483,25 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "伪装不同操作系统更加困难——系统级API更难以掩盖,使网站更容易检测到不一致之处。没有任何反检测浏览器能够完美伪装跨操作系统的所有细节。"
"crossOsWarning": "伪装不同操作系统的指纹不太可靠,因为不可能完美模拟所有底层组件。请谨慎使用。"
},
"syncAll": {
"title": "为现有项目启用同步",
"description": "您有未同步的项目。是否要为所有项目启用同步?",
"itemsList": "未同步项目: {{items}}",
"proxies": "{{count}} 个代理",
"proxies_plural": "{{count}} 个代理",
"groups": "{{count}} 个分组",
"groups_plural": "{{count}} 个分组",
"vpns": "{{count}} 个 VPN",
"vpns_plural": "{{count}} 个 VPN",
"enableAll": "全部启用",
"skip": "跳过",
"success": "已为所有项目启用同步"
},
"crossOs": {
"viewOnly": "此配置文件在 {{os}} 上创建,不受此系统支持",
"cannotLaunch": "此配置文件在 {{os}} 上创建,不受此系统支持",
"cannotModify": "无法修改跨操作系统配置文件的同步设置"
}
}
+20 -2
View File
@@ -15,8 +15,8 @@ export function getBrowserDisplayName(browserType: string): string {
zen: "Zen Browser",
brave: "Brave",
chromium: "Chromium",
camoufox: "Firefox (Camoufox)",
wayfern: "Chromium (Wayfern)",
camoufox: "Camoufox",
wayfern: "Wayfern",
};
return browserNames[browserType] || browserType;
@@ -48,3 +48,21 @@ export const getCurrentOS = () => {
}
return "unknown";
};
export function isCrossOsProfile(profile: { host_os?: string }): boolean {
if (!profile.host_os) return false;
return profile.host_os !== getCurrentOS();
}
export function getOSDisplayName(os: string): string {
switch (os) {
case "macos":
return "macOS";
case "windows":
return "Windows";
case "linux":
return "Linux";
default:
return os;
}
}
+4
View File
@@ -17,6 +17,7 @@ export interface BrowserProfile {
browser: string;
version: string;
proxy_id?: string; // Reference to stored proxy
vpn_id?: string; // Reference to stored VPN config
process_id?: number;
last_launch?: number;
release_type: string; // "stable" or "nightly"
@@ -27,6 +28,7 @@ export interface BrowserProfile {
note?: string; // User note
sync_enabled?: boolean; // Whether sync is enabled for this profile
last_sync?: number; // Timestamp of last successful sync (epoch seconds)
host_os?: string; // OS where profile was created ("macos", "windows", "linux")
}
export type SyncStatus = "Disabled" | "Syncing" | "Synced" | "Error";
@@ -604,6 +606,8 @@ export interface VpnConfig {
config_data: string; // Raw config content (may be empty in list view)
created_at: number;
last_used?: number;
sync_enabled?: boolean;
last_sync?: number;
}
export interface VpnImportResult {