mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-23 15:09:55 +02:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5663515a7 | |||
| 0f579cb97d | |||
| de896f895c | |||
| 3d57a622b1 | |||
| 5dfe7cb216 | |||
| dea0181009 | |||
| 4983f622d0 | |||
| 6654ab9fdc | |||
| d490ad3612 | |||
| e31de5ac99 | |||
| 7cd3e922f5 | |||
| 547bd89de9 | |||
| edabfd0831 | |||
| 127912c68c | |||
| af2aa36ac6 | |||
| d52493b7e4 | |||
| dfc94c10ff | |||
| a008e11504 | |||
| 6f28ed3a47 | |||
| c30a44a13d |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+3
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 |
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.14.2",
|
||||
"version": "0.14.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
|
||||
Generated
+111
-8
@@ -1409,6 +1409,47 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204"
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "0.3.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro-error2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.5"
|
||||
@@ -1481,7 +1522,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1547,7 +1588,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.14.2"
|
||||
version = "0.14.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -1596,6 +1637,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"single-instance",
|
||||
"smoltcp",
|
||||
"sys-locale",
|
||||
"sysinfo",
|
||||
"tao",
|
||||
@@ -1794,7 +1836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2516,6 +2558,15 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -2549,6 +2600,16 @@ dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
|
||||
dependencies = [
|
||||
"hash32",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@@ -2739,7 +2800,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.61.2",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3387,6 +3448,12 @@ dependencies = [
|
||||
"core-foundation-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "managed"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.14.1"
|
||||
@@ -4119,7 +4186,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4587,6 +4654,28 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr2"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-hack"
|
||||
version = "0.5.20+deprecated"
|
||||
@@ -5248,7 +5337,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5827,6 +5916,20 @@ version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "smoltcp"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a1a996951e50b5971a2c8c0fa05a381480d70a933064245c4a223ddc87ccc97"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"byteorder",
|
||||
"cfg-if",
|
||||
"defmt 0.3.100",
|
||||
"heapless",
|
||||
"managed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.2"
|
||||
@@ -6551,7 +6654,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7606,7 +7709,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.14.2"
|
||||
version = "0.14.5"
|
||||
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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
@@ -312,11 +335,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,
|
||||
@@ -2413,11 +2459,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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -602,11 +602,24 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
+189
-70
@@ -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,
|
||||
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,8 @@ 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,
|
||||
read_profile_cookies,
|
||||
copy_profile_cookies,
|
||||
check_wayfern_terms_accepted,
|
||||
@@ -1208,6 +1326,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 +1368,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
@@ -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!({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -243,8 +243,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 +477,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 +1625,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}");
|
||||
|
||||
@@ -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,85 @@ 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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
enable_group_sync_if_needed, enable_proxy_sync_if_needed, enable_vpn_sync_if_needed,
|
||||
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,
|
||||
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};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,67 @@ 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 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: false,
|
||||
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,
|
||||
@@ -333,6 +404,8 @@ impl VpnStorage {
|
||||
config_data: content.to_string(),
|
||||
created_at: Utc::now().timestamp(),
|
||||
last_used: None,
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
};
|
||||
|
||||
self.save_config(&config)?;
|
||||
@@ -375,6 +448,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 +472,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 +483,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 +509,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();
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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>()
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.14.2",
|
||||
"version": "0.14.5",
|
||||
"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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+69
-12
@@ -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";
|
||||
@@ -35,8 +35,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 +83,8 @@ export default function Home() {
|
||||
error: proxiesError,
|
||||
} = useProxyEvents();
|
||||
|
||||
const { vpnConfigs } = useVpnEvents();
|
||||
|
||||
// Wayfern terms and commercial trial hooks
|
||||
const {
|
||||
termsAccepted,
|
||||
@@ -92,7 +100,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);
|
||||
@@ -139,6 +149,8 @@ export default function Home() {
|
||||
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||
usePermissions();
|
||||
|
||||
const userInitiatedSyncIds = useRef<Set<string>>(new Set());
|
||||
|
||||
const handleSelectGroup = useCallback((groupId: string) => {
|
||||
setSelectedGroupId(groupId);
|
||||
setSelectedProfiles([]);
|
||||
@@ -446,6 +458,7 @@ export default function Home() {
|
||||
version: string;
|
||||
releaseType: string;
|
||||
proxyId?: string;
|
||||
vpnId?: string;
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
wayfernConfig?: WayfernConfig;
|
||||
groupId?: string;
|
||||
@@ -457,6 +470,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 +688,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 +709,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 +1089,7 @@ export default function Home() {
|
||||
onAssignmentComplete={handleProxyAssignmentComplete}
|
||||
profiles={profiles}
|
||||
storedProxies={storedProxies}
|
||||
vpnConfigs={vpnConfigs}
|
||||
/>
|
||||
|
||||
<CookieCopyDialog
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
@@ -946,10 +958,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 +971,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 +981,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 +1145,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 +1158,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 +1168,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>
|
||||
|
||||
@@ -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,66 @@ 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>Created on {osName} - view only</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>Created on {osName} - view only</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 +1870,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 +1919,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 +1952,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 +1979,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 +2070,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 +2106,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 +2115,7 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No proxies found.</CommandEmpty>
|
||||
<CommandEmpty>No proxies or VPNs found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
@@ -1930,12 +2126,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 +2147,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 +2156,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 +2222,7 @@ export function ProfilesDataTable({
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
{profileHasProxy && effectiveProxy && !isDisabled && (
|
||||
{effectiveProxy && !effectiveVpn && !isDisabled && (
|
||||
<ProxyCheckButton
|
||||
proxy={effectiveProxy}
|
||||
profileId={profile.id}
|
||||
@@ -2023,26 +2251,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 +2284,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 +2292,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 +2318,7 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
meta.onOpenTrafficDialog?.(profile.id);
|
||||
}}
|
||||
disabled={isCrossOs}
|
||||
>
|
||||
View Network
|
||||
</DropdownMenuItem>
|
||||
@@ -2086,7 +2328,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 +2352,7 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
meta.onConfigureCamoufox?.(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Change Fingerprint
|
||||
</DropdownMenuItem>
|
||||
@@ -2121,6 +2364,7 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
meta.onCopyCookiesToProfile?.(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Copy Cookies to Profile
|
||||
</DropdownMenuItem>
|
||||
@@ -2137,7 +2381,7 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
setProfileToDelete(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
disabled={isDeleteDisabled}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -2210,7 +2454,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">
|
||||
|
||||
@@ -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}
|
||||
• {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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 `Created on ${osName}. Can only be launched on ${osName}.`;
|
||||
}
|
||||
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
const isStopping = stoppingProfiles.has(profile.id);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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": {
|
||||
@@ -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..."
|
||||
}
|
||||
},
|
||||
@@ -481,5 +484,10 @@
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"crossOs": {
|
||||
"viewOnly": "Created on {{os}} - view only",
|
||||
"cannotLaunch": "Created on {{os}}. Can only be launched on {{os}}.",
|
||||
"cannotModify": "Cannot modify sync settings for a cross-OS profile"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
@@ -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..."
|
||||
}
|
||||
},
|
||||
@@ -481,5 +484,10 @@
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"crossOs": {
|
||||
"viewOnly": "Creado en {{os}} - solo lectura",
|
||||
"cannotLaunch": "Creado en {{os}}. Solo se puede iniciar en {{os}}.",
|
||||
"cannotModify": "No se pueden modificar los ajustes de sincronización de un perfil de otro sistema operativo"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
@@ -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..."
|
||||
}
|
||||
},
|
||||
@@ -481,5 +484,10 @@
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"crossOs": {
|
||||
"viewOnly": "Créé sur {{os}} - lecture seule",
|
||||
"cannotLaunch": "Créé sur {{os}}. Ne peut être lancé que sur {{os}}.",
|
||||
"cannotModify": "Impossible de modifier les paramètres de synchronisation d'un profil d'un autre système d'exploitation"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
@@ -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": "ブラウザバージョンを更新中..."
|
||||
}
|
||||
},
|
||||
@@ -481,5 +484,10 @@
|
||||
},
|
||||
"fingerprint": {
|
||||
"crossOsWarning": "異なるオペレーティングシステムの偽装はより困難です。システムレベルのAPIはマスクしにくく、ウェブサイトが矛盾を検出しやすくなります。どのアンチディテクトブラウザも、異なるOS間のすべての詳細を完璧に偽装することはできません。"
|
||||
},
|
||||
"crossOs": {
|
||||
"viewOnly": "{{os}}で作成 - 閲覧のみ",
|
||||
"cannotLaunch": "{{os}}で作成されました。{{os}}でのみ起動できます。",
|
||||
"cannotModify": "他のOSのプロファイルの同期設定は変更できません"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
@@ -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..."
|
||||
}
|
||||
},
|
||||
@@ -481,5 +484,10 @@
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"crossOs": {
|
||||
"viewOnly": "Criado em {{os}} - somente leitura",
|
||||
"cannotLaunch": "Criado em {{os}}. Só pode ser iniciado em {{os}}.",
|
||||
"cannotModify": "Não é possível modificar as configurações de sincronização de um perfil de outro sistema operacional"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
@@ -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": "Обновление версий браузеров..."
|
||||
}
|
||||
},
|
||||
@@ -481,5 +484,10 @@
|
||||
},
|
||||
"fingerprint": {
|
||||
"crossOsWarning": "Подмена другой операционной системы сложнее — системные API труднее замаскировать, что упрощает обнаружение несоответствий веб-сайтами. Ни один антидетект-браузер не может идеально подменить все детали при смене операционной системы."
|
||||
},
|
||||
"crossOs": {
|
||||
"viewOnly": "Создан на {{os}} - только просмотр",
|
||||
"cannotLaunch": "Создан на {{os}}. Может быть запущен только на {{os}}.",
|
||||
"cannotModify": "Невозможно изменить настройки синхронизации профиля другой ОС"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
@@ -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": "正在更新浏览器版本..."
|
||||
}
|
||||
},
|
||||
@@ -481,5 +484,10 @@
|
||||
},
|
||||
"fingerprint": {
|
||||
"crossOsWarning": "伪装不同的操作系统更加困难——系统级API更难以掩盖,使网站更容易检测到不一致之处。没有任何反检测浏览器能够完美伪装跨操作系统的所有细节。"
|
||||
},
|
||||
"crossOs": {
|
||||
"viewOnly": "在 {{os}} 上创建 - 仅查看",
|
||||
"cannotLaunch": "在 {{os}} 上创建。只能在 {{os}} 上启动。",
|
||||
"cannotModify": "无法修改跨操作系统配置文件的同步设置"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user