Compare commits

..

25 Commits

Author SHA1 Message Date
zhom b57523fa1e refactor: better cleanup 2026-04-19 22:43:16 +04:00
zhom d637b3036b chore: version bump 2026-04-19 21:44:13 +04:00
zhom a1170b586a chore: linting 2026-04-19 21:07:10 +04:00
zhom c4c6ec9dfd refactor: proxy cleanup 2026-04-19 19:40:55 +04:00
zhom 3152e0de59 feat: shadowsocks 2026-04-19 19:40:55 +04:00
andy 8284b62e34 Merge pull request #291 from zhom/dependabot/github_actions/github-actions-2ccc4691dc
ci(deps): bump the github-actions group with 3 updates
2026-04-18 12:06:18 +02:00
dependabot[bot] 1bd3a9d123 ci(deps): bump the github-actions group with 3 updates
Bumps the github-actions group with 3 updates: [pnpm/action-setup](https://github.com/pnpm/action-setup), [anomalyco/opencode](https://github.com/anomalyco/opencode) and [crate-ci/typos](https://github.com/crate-ci/typos).


Updates `pnpm/action-setup` from 6.0.0 to 6.0.1
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/08c4be7e2e672a47d11bd04269e27e5f3e8529cb...078e9d416474b29c0c387560859308974f7e9c53)

Updates `anomalyco/opencode` from 1.4.3 to 1.4.11
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/877be7e8e04142cd8fbebcb5e6c4b9617bf28cce...a35b8a95c27d28e979a3826e1289d7ee87f40251)

Updates `crate-ci/typos` from 1.45.0 to 1.45.1
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/02ea592e44b3a53c302f697cddca7641cd051c3d...cf5f1c29a8ac336af8568821ec41919923b05a83)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.4.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.45.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-18 09:04:48 +00:00
github-actions[bot] adb1335564 chore: update flake.nix for v0.21.0 [skip ci] (#289)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-16 13:23:52 +00:00
github-actions[bot] 0f2d0b1b3b docs: update CHANGELOG.md and README.md for v0.21.0 [skip ci] (#288)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-16 13:23:32 +00:00
zhom 9f4bb594e4 fix: vpn config discovery 2026-04-16 13:32:29 +04:00
zhom f338d08be1 chore: version bump 2026-04-16 08:16:23 +04:00
zhom e293c36b97 refactor: cleanup 2026-04-16 08:15:58 +04:00
dependabot[bot] ba796f1cea deps(rust)(deps): bump rand from 0.10.0 to 0.10.1 in /src-tauri (#285)
Bumps [rand](https://github.com/rust-random/rand) from 0.10.0 to 0.10.1.
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/0.10.0...0.10.1)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.10.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 01:51:57 +00:00
zhom bd052cec38 refactor: stricter proxy cleanup 2026-04-13 02:57:22 +04:00
zhom dfc8f80ba5 refactor: wayfern launch 2026-04-13 02:47:16 +04:00
zhom ce63eccfa4 feat: shadowsocks 2026-04-12 13:54:50 +04:00
zhom 3608331a28 chore: proper formatting 2026-04-12 13:54:50 +04:00
zhom cb5b667ef9 style: button should not become bigger on hover 2026-04-12 13:54:50 +04:00
zhom 7cb541b6c7 style: scrollbars 2026-04-12 13:54:50 +04:00
zhom ace0f40320 refactor: better error handling 2026-04-12 13:54:50 +04:00
zhom 1c118ffe37 refactor: self-updates 2026-04-12 13:54:50 +04:00
zhom 3a8721edf4 chore: remove pre-installed aws cli 2026-04-12 13:54:50 +04:00
zhom feb7afaf30 refactor: x64 performance 2026-04-12 13:54:50 +04:00
github-actions[bot] 0189d2ec39 chore: update flake.nix for v0.20.4 [skip ci] (#283)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-11 21:14:39 +00:00
github-actions[bot] f7e38b737d docs: update CHANGELOG.md and README.md for v0.20.4 [skip ci] (#282)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-11 21:14:18 +00:00
49 changed files with 1596 additions and 378 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb #v6.0.0
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
with:
run_install: false
+1 -1
View File
@@ -327,7 +327,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Run opencode
uses: anomalyco/opencode/github@877be7e8e04142cd8fbebcb5e6c4b9617bf28cce #v1.4.3
uses: anomalyco/opencode/github@a35b8a95c27d28e979a3826e1289d7ee87f40251 #v1.4.11
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb #v6.0.0
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
with:
run_install: false
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb #v6.0.0
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
with:
run_install: false
+9 -6
View File
@@ -60,13 +60,16 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y dpkg-dev createrepo-c python3-pip
# Install aws-cli v1 via pip, overriding the pre-installed v2.
# aws-cli v2.23+ sends CRC64NVME checksums that Cloudflare R2 rejects
# with Unauthorized, and the s3transfer lib used by `aws s3 sync` has
# a confirmed bug where WHEN_REQUIRED env var is silently ignored
# (boto/s3transfer#327). aws-cli v1 never sends these headers at all
# and matches the proven local Docker publish path.
# Remove pre-installed aws-cli v2 — it sends CRC64NVME checksums
# that Cloudflare R2 rejects with Unauthorized, and the s3transfer
# lib has a confirmed bug where WHEN_REQUIRED is silently ignored
# (boto/s3transfer#327). Install aws-cli v1 via pip instead.
sudo rm -f /usr/local/bin/aws /usr/local/bin/aws_completer
sudo rm -rf /usr/local/aws-cli
pip3 install --break-system-packages awscli
# Ensure pip-installed aws is on PATH (pip may install to ~/.local/bin)
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
aws --version
- name: Download packages from GitHub release
env:
+2 -2
View File
@@ -108,7 +108,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb #v6.0.0
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
with:
run_install: false
@@ -492,7 +492,7 @@ jobs:
CHANGES="See the full changelog on GitHub."
fi
printf '%s' "$CHANGES" > /tmp/discord-changes.txt
printf '%b' "$CHANGES" > /tmp/discord-changes.txt
- name: Send Discord notification
env:
+1 -1
View File
@@ -107,7 +107,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb #v6.0.0
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
with:
run_install: false
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Checkout Actions Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Spell Check Repo
uses: crate-ci/typos@02ea592e44b3a53c302f697cddca7641cd051c3d #v1.45.0
uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 #v1.45.1
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
uses: actions/checkout@v6.0.2
- name: Install pnpm
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb #v6.0.0
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
with:
run_install: false
@@ -94,7 +94,7 @@ jobs:
done
- name: Install pnpm
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb #v6.0.0
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
with:
run_install: false
+1 -1
View File
@@ -58,4 +58,4 @@ nodecar/nodecar-bin
.env
# next
next-env.d.ts
**/next-env.d.ts
+55
View File
@@ -1,6 +1,61 @@
# Changelog
## v0.21.0 (2026-04-16)
### Features
- shadowsocks
### Bug Fixes
- vpn config discovery
### Refactoring
- cleanup
- stricter proxy cleanup
- wayfern launch
- better error handling
- self-updates
- x64 performance
### Maintenance
- chore: version bump
- chore: proper formatting
- chore: remove pre-installed aws cli
- chore: update flake.nix for v0.20.4 [skip ci] (#283)
### Other
- deps(rust)(deps): bump rand from 0.10.0 to 0.10.1 in /src-tauri (#285)
- style: button should not become bigger on hover
- style: scrollbars
## v0.20.4 (2026-04-11)
### Refactoring
- vpn
- save port
### Maintenance
- chore: version bump
- chore: linting
- chore: overwrite aws cli
- ci(deps): bump the github-actions group with 3 updates
- chore: update flake.nix for v0.20.3 [skip ci] (#278)
### Other
- style: copy
- deps(rust)(deps): bump the rust-dependencies group
- deps(deps): bump next from 16.2.2 to 16.2.3
## v0.20.3 (2026-04-10)
### Refactoring
+5 -5
View File
@@ -51,7 +51,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.3/Donut_0.20.3_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.3/Donut_0.20.3_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.0/Donut_0.21.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.0/Donut_0.21.0_x64.dmg) |
Or install via Homebrew:
@@ -61,15 +61,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.20.3/Donut_0.20.3_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.20.3/Donut_0.20.3_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.21.0/Donut_0.21.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.21.0/Donut_0.21.0_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.3/Donut_0.20.3_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.3/Donut_0.20.3_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.3/Donut-0.20.3-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.3/Donut-0.20.3-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.3/Donut_0.20.3_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.3/Donut_0.20.3_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.0/Donut_0.21.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.0/Donut_0.21.0_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.0/Donut-0.21.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.0/Donut-0.21.0-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.0/Donut_0.21.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.21.0/Donut_0.21.0_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
+5 -5
View File
@@ -94,17 +94,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.20.3";
releaseVersion = "0.21.0";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.20.3/Donut_0.20.3_amd64.AppImage";
hash = "sha256-w0+DdP12xVWUE3TOPrW/CxsaYh19zdROmVGGxVzB0lI=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.21.0/Donut_0.21.0_amd64.AppImage";
hash = "sha256-Qrg+8uh9RTDMHUNqWChWBHIIsy2Dgzu5wOH+FuPN35k=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.20.3/Donut_0.20.3_aarch64.AppImage";
hash = "sha256-3X02E8cS0tbxPQLmLnIbt6ngZVBwpIVaVkIRzku9zOE=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.21.0/Donut_0.21.0_aarch64.AppImage";
hash = "sha256-UBGer3/8xleadHaZ/5OY2KaC03OE40SOewCAdcxw2CM=";
}
else
null;
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.20.4",
"version": "0.21.1",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
+249 -22
View File
@@ -782,6 +782,12 @@ dependencies = [
"utf8-width",
]
[[package]]
name = "byte_string"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11aade7a05aa8c3a351cedc44c3fc45806430543382fcc4743a9b757a2a0b4ed"
[[package]]
name = "bytecheck"
version = "0.6.12"
@@ -1151,6 +1157,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
@@ -1588,6 +1600,16 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid 0.9.6",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -1661,7 +1683,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
dependencies = [
"block-buffer 0.12.0",
"const-oid",
"const-oid 0.10.2",
"crypto-common 0.2.1",
]
@@ -1767,7 +1789,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.20.4"
version = "0.21.1"
dependencies = [
"aes 0.9.0",
"aes-gcm",
@@ -1809,7 +1831,7 @@ dependencies = [
"once_cell",
"playwright",
"quick-xml 0.39.2",
"rand 0.10.0",
"rand 0.10.1",
"regex-lite",
"reqwest 0.13.2",
"resvg",
@@ -1820,6 +1842,7 @@ dependencies = [
"serde_yaml",
"serial_test",
"sha2 0.11.0",
"shadowsocks",
"smoltcp",
"sys-locale",
"sysinfo",
@@ -1890,6 +1913,36 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "dynosaur"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a12303417f378f29ba12cb12fc78a9df0d8e16ccb1ad94abf04d48d96bdda532"
dependencies = [
"dynosaur_derive",
]
[[package]]
name = "dynosaur_derive"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b0713d5c1d52e774c5cd7bb8b043d7c0fc4f921abfb678556140bfbe6ab2364"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "either"
version = "1.15.0"
@@ -2855,6 +2908,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
@@ -3023,7 +3085,7 @@ dependencies = [
"tower-layer",
"tower-service",
"tracing",
"windows-registry 0.6.1",
"windows-registry",
]
[[package]]
@@ -3038,7 +3100,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
"windows-core 0.61.2",
]
[[package]]
@@ -3820,6 +3882,16 @@ dependencies = [
"rayon",
]
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest 0.10.7",
]
[[package]]
name = "memchr"
version = "2.8.0"
@@ -4789,6 +4861,26 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-project"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -4806,6 +4898,16 @@ dependencies = [
"futures-io",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -4821,7 +4923,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "playwright"
version = "0.0.23"
source = "git+https://github.com/sctg-development/playwright-rust?branch=master#77d7a9729bc6c45b899a61eb4fb84adf075315e2"
source = "git+https://github.com/zhom/playwright-rust?branch=master#95a6c94d87c88376502ce2f33d4c61c09fc008a6"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -5205,9 +5307,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.10.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20 0.10.0",
"getrandom 0.4.2",
@@ -5612,6 +5714,19 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "ring-compat"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccce7bae150b815f0811db41b8312fcb74bffa4cab9cee5429ee00f356dd5bd4"
dependencies = [
"aead",
"ed25519",
"generic-array",
"pkcs8",
"ring",
]
[[package]]
name = "rkyv"
version = "0.7.46"
@@ -5760,9 +5875,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.11"
version = "0.103.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4"
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
dependencies = [
"ring",
"rustls-pki-types",
@@ -5895,6 +6010,17 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "sealed"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -5965,6 +6091,16 @@ dependencies = [
"serde_core",
]
[[package]]
name = "sendfd"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b183bfd5b1bc64ab0c1ef3ee06b008a9ef1b68a7d3a99ba566fbfe7a7c6d745b"
dependencies = [
"libc",
"tokio",
]
[[package]]
name = "serde"
version = "1.0.228"
@@ -6227,6 +6363,55 @@ dependencies = [
"digest 0.11.2",
]
[[package]]
name = "shadowsocks"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "482831bf9d55acf3c98e211b6c852c3dfdf1d1b0d23fdf1d887c5a4b2acad4e4"
dependencies = [
"base64 0.22.1",
"blake3",
"byte_string",
"bytes",
"cfg-if",
"dynosaur",
"futures",
"libc",
"log",
"percent-encoding",
"pin-project",
"sealed",
"sendfd",
"serde",
"serde_json",
"serde_urlencoded",
"shadowsocks-crypto",
"socket2",
"spin",
"thiserror 2.0.18",
"tokio",
"tokio-tfo",
"trait-variant",
"url",
"windows-sys 0.61.2",
]
[[package]]
name = "shadowsocks-crypto"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d038a3d17586f1c1ab3c1c3b9e4d5ef8fba98fb3890ad740c8487038b2e2ca5"
dependencies = [
"aes-gcm",
"cfg-if",
"chacha20poly1305",
"hkdf",
"md-5",
"rand 0.9.2",
"ring-compat",
"sha1",
]
[[package]]
name = "shared_child"
version = "1.1.1"
@@ -6275,6 +6460,12 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
[[package]]
name = "simd-adler32"
version = "0.3.9"
@@ -6410,6 +6601,25 @@ dependencies = [
"system-deps",
]
[[package]]
name = "spin"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.2"
@@ -6887,7 +7097,7 @@ dependencies = [
"thiserror 2.0.18",
"tracing",
"url",
"windows-registry 0.5.3",
"windows-registry",
"windows-result 0.3.4",
]
@@ -7369,6 +7579,23 @@ dependencies = [
"tokio-util",
]
[[package]]
name = "tokio-tfo"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6ad2c3b3bb958ad992354a7ebc468fc0f7cdc9af4997bf4d3fd3cb28bad36dc"
dependencies = [
"cfg-if",
"futures",
"libc",
"log",
"once_cell",
"pin-project",
"socket2",
"tokio",
"windows-sys 0.60.2",
]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
@@ -7601,6 +7828,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "trait-variant"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tray-icon"
version = "0.21.3"
@@ -8493,17 +8731,6 @@ dependencies = [
"windows-strings 0.4.2",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
+4 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.20.4"
version = "0.21.1"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -76,7 +76,7 @@ chrono-tz = "0.10"
axum = { version = "0.8.8", features = ["ws"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] }
rand = "0.10.0"
rand = "0.10.1"
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.2"
argon2 = "0.5"
@@ -85,6 +85,7 @@ aes = "0.9"
cbc = "0.2"
ring = "0.17"
sha2 = "0.11"
shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] }
hyper = { version = "1.8", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
@@ -92,7 +93,7 @@ clap = { version = "4", features = ["derive"] }
async-socks5 = "0.6"
# Camoufox/Playwright integration
playwright = { git = "https://github.com/sctg-development/playwright-rust", branch = "master" }
playwright = { git = "https://github.com/zhom/playwright-rust", branch = "master" }
# Wayfern CDP integration
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
+207 -134
View File
@@ -988,6 +988,10 @@ impl AppAutoUpdater {
&format!("{}.log", installer_path.to_str().unwrap()),
]);
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
let output = cmd.output()?;
if !output.status.success() {
@@ -1178,41 +1182,7 @@ impl AppAutoUpdater {
deb_path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
log::info!("Installing DEB package: {}", deb_path.display());
// Try different package managers in order of preference
let package_managers = [
("dpkg", vec!["-i", deb_path.to_str().unwrap()]),
("apt", vec!["install", "-y", deb_path.to_str().unwrap()]),
];
let mut last_error = String::new();
for (manager, args) in &package_managers {
// Check if package manager exists
if Command::new("which").arg(manager).output().is_ok() {
log::info!("Trying to install with {manager}");
let output = Command::new("pkexec").arg(manager).args(args).output();
match output {
Ok(output) if output.status.success() => {
log::info!("DEB installation completed successfully with {manager}");
return Ok(());
}
Ok(output) => {
let error_msg = String::from_utf8_lossy(&output.stderr);
last_error = format!("{manager} failed: {error_msg}");
log::info!("Installation failed with {manager}: {error_msg}");
}
Err(e) => {
last_error = format!("Failed to execute {manager}: {e}");
log::info!("Failed to execute {manager}: {e}");
}
}
}
}
Err(format!("DEB installation failed. Last error: {last_error}").into())
Self::install_linux_package_with_privileges(deb_path, "dpkg", "-i")
}
/// Install Linux RPM package
@@ -1222,43 +1192,121 @@ impl AppAutoUpdater {
rpm_path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
log::info!("Installing RPM package: {}", rpm_path.display());
Self::install_linux_package_with_privileges(rpm_path, "rpm", "-Uvh")
}
// Try different package managers in order of preference
let package_managers = [
("rpm", vec!["-Uvh", rpm_path.to_str().unwrap()]),
("dnf", vec!["install", "-y", rpm_path.to_str().unwrap()]),
("yum", vec!["install", "-y", rpm_path.to_str().unwrap()]),
("zypper", vec!["install", "-y", rpm_path.to_str().unwrap()]),
];
/// Install a Linux package with privilege escalation, using a fallback chain:
/// 1. pkexec (graphical PolicyKit prompt — most common on desktop Linux)
/// 2. zenity/kdialog password dialog → sudo -S (graphical sudo experience)
/// 3. sudo (terminal fallback — works in TTY sessions)
#[cfg(target_os = "linux")]
fn install_linux_package_with_privileges(
pkg_path: &Path,
install_cmd: &str,
install_arg: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let pkg = pkg_path.to_str().unwrap_or_default();
let mut last_error = String::new();
// 1. Try pkexec (graphical PolicyKit prompt)
if let Ok(status) = Command::new("pkexec")
.args([install_cmd, install_arg, pkg])
.status()
{
if status.success() {
log::info!("Installed {pkg} with pkexec");
return Ok(());
}
}
for (manager, args) in &package_managers {
// Check if package manager exists
if Command::new("which").arg(manager).output().is_ok() {
log::info!("Trying to install with {manager}");
// 2. Try graphical password dialog → sudo -S
if let Some(password) = Self::get_password_graphically() {
if Self::install_with_sudo_stdin(pkg_path, &password, install_cmd, install_arg) {
log::info!("Installed {pkg} with graphical sudo");
return Ok(());
}
}
let output = Command::new("pkexec").arg(manager).args(args).output();
// 3. Terminal sudo fallback
if let Ok(status) = Command::new("sudo")
.args([install_cmd, install_arg, pkg])
.status()
{
if status.success() {
log::info!("Installed {pkg} with sudo");
return Ok(());
}
}
match output {
Ok(output) if output.status.success() => {
log::info!("RPM installation completed successfully with {manager}");
return Ok(());
}
Ok(output) => {
let error_msg = String::from_utf8_lossy(&output.stderr);
last_error = format!("{manager} failed: {error_msg}");
log::info!("Installation failed with {manager}: {error_msg}");
}
Err(e) => {
last_error = format!("Failed to execute {manager}: {e}");
log::info!("Failed to execute {manager}: {e}");
}
Err(format!("Failed to install {pkg} — all privilege escalation methods failed").into())
}
/// Try zenity then kdialog to get a password graphically.
#[cfg(target_os = "linux")]
fn get_password_graphically() -> Option<String> {
// Try zenity
if let Ok(output) = Command::new("zenity")
.args([
"--password",
"--title=Authentication Required",
"--text=Enter your password to install the update:",
])
.output()
{
if output.status.success() {
let pw = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !pw.is_empty() {
return Some(pw);
}
}
}
Err(format!("RPM installation failed. Last error: {last_error}").into())
// Fall back to kdialog
if let Ok(output) = Command::new("kdialog")
.args(["--password", "Enter your password to install the update:"])
.output()
{
if output.status.success() {
let pw = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !pw.is_empty() {
return Some(pw);
}
}
}
None
}
/// Pipe a password to `sudo -S <install_cmd> <install_arg> <pkg>`.
#[cfg(target_os = "linux")]
fn install_with_sudo_stdin(
pkg_path: &Path,
password: &str,
install_cmd: &str,
install_arg: &str,
) -> bool {
use std::io::Write;
let child = Command::new("sudo")
.args([
"-S",
install_cmd,
install_arg,
pkg_path.to_str().unwrap_or_default(),
])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn();
match child {
Ok(mut child) => {
if let Some(mut stdin) = child.stdin.take() {
let _ = writeln!(stdin, "{password}");
}
child.wait().map(|s| s.success()).unwrap_or(false)
}
Err(_) => false,
}
}
/// Install Linux AppImage
@@ -1474,96 +1522,121 @@ rm "{}"
#[cfg(target_os = "windows")]
{
let app_path = self.get_current_app_path()?;
let current_pid = std::process::id();
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
let pending = PENDING_INSTALLER_PATH.lock().unwrap().take();
let temp_dir = std::env::temp_dir();
let script_path = temp_dir.join("donut_restart.bat");
let update_temp_dir = temp_dir.join("donut_app_update");
let script_content = if let Some(installer_path) = pending {
if let Some(installer_path) = pending {
// Use ShellExecuteW to run the installer directly — no batch script,
// no cmd.exe console window. The NSIS/MSI installer handles killing the
// old process and restarting the app natively (via /UPDATE and
// AUTOLAUNCHAPP flags).
let ext = installer_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let install_cmd = match ext.as_str() {
"msi" => format!(
"msiexec /i \"{}\" /quiet /norestart REBOOT=ReallySuppress",
installer_path.to_str().unwrap()
),
"exe" => format!("\"{}\" /S", installer_path.to_str().unwrap()),
_ => String::new(),
let (file, parameters) = match ext.as_str() {
"exe" => {
// NSIS installer: /S for silent, /UPDATE tells it this is an update
let file = installer_path.as_os_str().to_os_string();
let params = std::ffi::OsString::from("/S /UPDATE");
(file, params)
}
"msi" => {
// MSI: run msiexec.exe with the package
let msiexec = std::env::var("SYSTEMROOT")
.map(|p| format!("{p}\\System32\\msiexec.exe"))
.unwrap_or_else(|_| "msiexec.exe".to_string());
let file = std::ffi::OsString::from(msiexec);
let params = std::ffi::OsString::from(format!(
"/i {} /quiet /norestart /promptrestart AUTOLAUNCHAPP=True",
installer_path
.to_str()
.map(|p| format!("\"{p}\""))
.unwrap_or_default()
));
(file, params)
}
_ => {
return Err("Unsupported Windows installer format for restart".into());
}
};
format!(
r#"@echo off
rem Wait for the current process to exit
:wait_loop
tasklist /fi "PID eq {pid}" >nul 2>&1
if %errorlevel% equ 0 (
timeout /t 1 /nobreak >nul
goto wait_loop
)
fn encode_wide(s: impl AsRef<OsStr>) -> Vec<u16> {
s.as_ref().encode_wide().chain(std::iter::once(0)).collect()
}
rem Wait a bit more to ensure clean exit
timeout /t 2 /nobreak >nul
let file_w = encode_wide(&file);
let params_w = encode_wide(&parameters);
rem Run the installer
{install_cmd}
log::info!(
"Running installer via ShellExecuteW: {:?} {:?}",
file,
parameters
);
rem Wait for installation to complete
timeout /t 3 /nobreak >nul
// windows-sys is not a direct dep, so use the raw FFI via the
// windows crate that Tauri pulls in. ShellExecuteW returns an
// HINSTANCE > 32 on success.
#[link(name = "shell32")]
extern "system" {
fn ShellExecuteW(
hwnd: *mut std::ffi::c_void,
operation: *const u16,
file: *const u16,
parameters: *const u16,
directory: *const u16,
show_cmd: i32,
) -> isize;
}
const SW_SHOWNORMAL: i32 = 1;
let open: Vec<u16> = "open\0".encode_utf16().collect();
rem Start the new application
start "" "{app_path}"
let result = unsafe {
ShellExecuteW(
std::ptr::null_mut(),
open.as_ptr(),
file_w.as_ptr(),
params_w.as_ptr(),
std::ptr::null(),
SW_SHOWNORMAL,
)
};
rem Clean up installer temp files
rmdir /s /q "{update_temp}"
rem Clean up this script
del "%~f0"
"#,
pid = current_pid,
install_cmd = install_cmd,
app_path = app_path.to_str().unwrap(),
update_temp = update_temp_dir.to_str().unwrap(),
)
if result as usize <= 32 {
return Err(format!("ShellExecuteW failed with code {result}").into());
}
} else {
format!(
r#"@echo off
rem Wait for the current process to exit
:wait_loop
tasklist /fi "PID eq {}" >nul 2>&1
if %errorlevel% equ 0 (
timeout /t 1 /nobreak >nul
goto wait_loop
)
// No pending installer — just restart the app. Use a minimal
// detached process to relaunch after we exit.
let app_path = self.get_current_app_path()?;
let current_pid = std::process::id();
let temp_dir = std::env::temp_dir();
let script_path = temp_dir.join("donut_restart.bat");
rem Wait a bit more to ensure clean exit
timeout /t 2 /nobreak >nul
let script_content = format!(
"@echo off\n\
:w\n\
tasklist /fi \"PID eq {current_pid}\" 2>nul | find \"{current_pid}\" >nul && (timeout /t 1 /nobreak >nul & goto w)\n\
timeout /t 1 /nobreak >nul\n\
start \"\" \"{app}\"\n\
del \"%~f0\"\n",
app = app_path.to_str().unwrap(),
);
fs::write(&script_path, script_content)?;
rem Start the new application
start "" "{}"
rem Clean up this script
del "%~f0"
"#,
current_pid,
app_path.to_str().unwrap()
)
};
fs::write(&script_path, script_content)?;
let mut cmd = Command::new("cmd");
cmd.args(["/C", script_path.to_str().unwrap()]);
let _child = cmd.spawn()?;
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _child = Command::new("cmd")
.args(["/C", script_path.to_str().unwrap()])
.creation_flags(CREATE_NO_WINDOW)
.spawn()?;
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
std::process::exit(0);
}
+45 -9
View File
@@ -121,7 +121,7 @@ async fn main() {
.arg(
Arg::new("type")
.long("type")
.help("Proxy type (http, https, socks4, socks5)"),
.help("Proxy type (http, https, socks4, socks5, ss)"),
)
.arg(Arg::new("username").long("username").help("Proxy username"))
.arg(Arg::new("password").long("password").help("Proxy password"))
@@ -198,7 +198,12 @@ async fn main() {
.required(true)
.help("Local SOCKS5 port"),
)
.arg(Arg::new("action").required(true).help("Action (start)")),
.arg(Arg::new("action").required(true).help("Action (start)"))
.arg(
Arg::new("config-path")
.long("config-path")
.help("Direct path to the VPN worker config JSON file"),
),
)
.subcommand(
Command::new("mcp-bridge")
@@ -391,6 +396,7 @@ async fn main() {
let port = *vpn_matches
.get_one::<u16>("port")
.expect("port is required");
let config_path = vpn_matches.get_one::<String>("config-path");
if action == "start" {
set_high_priority();
@@ -398,8 +404,37 @@ async fn main() {
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 config = if let Some(path) = config_path {
// Load config directly from the provided path
log::info!("Loading VPN worker config from: {}", path);
match std::fs::read_to_string(path) {
Ok(content) => match serde_json::from_str::<
donutbrowser_lib::vpn_worker_storage::VpnWorkerConfig,
>(&content)
{
Ok(config) => {
log::info!(
"Found VPN worker config: id={}, vpn_type={}, vpn_id={}",
config.id,
config.vpn_type,
config.vpn_id
);
config
}
Err(e) => {
log::error!("Failed to parse VPN worker config from {}: {}", path, e);
process::exit(1);
}
},
Err(e) => {
log::error!("Failed to read VPN worker config from {}: {}", path, e);
process::exit(1);
}
}
} else {
// Fallback: discover config by ID with retries
let storage_dir = donutbrowser_lib::proxy_storage::get_storage_dir();
log::info!("Looking for VPN worker config in: {:?}", storage_dir);
let mut attempts = 0;
loop {
if let Some(config) = donutbrowser_lib::vpn_worker_storage::get_vpn_worker_config(id) {
@@ -412,20 +447,21 @@ async fn main() {
break config;
}
attempts += 1;
if attempts >= 10 {
if attempts >= 50 {
log::error!(
"VPN worker configuration {} not found after {} attempts",
"VPN worker configuration {} not found after {} attempts in {:?}",
id,
attempts
attempts,
storage_dir
);
process::exit(1);
}
log::info!(
"VPN worker config {} not found yet, retrying ({}/10)...",
"VPN worker config {} not found yet, retrying ({}/50)...",
id,
attempts
);
std::thread::sleep(std::time::Duration::from_millis(50));
std::thread::sleep(std::time::Duration::from_millis(100));
}
};
+1 -1
View File
@@ -4,7 +4,7 @@ use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProxySettings {
pub proxy_type: String, // "http", "https", "socks4", or "socks5"
pub proxy_type: String, // "http", "https", "socks4", "socks5", or "ss" (Shadowsocks)
pub host: String,
pub port: u16,
pub username: Option<String>,
+50
View File
@@ -659,6 +659,56 @@ impl CamoufoxManager {
}
}
// Write explicit proxy prefs to user.js so Firefox always uses the local
// donut-proxy and never falls back to stale proxy settings baked into prefs.js
// from a previous session. user.js values override prefs.js on every launch.
if let Some(proxy_str) = &config.proxy {
let user_js_path = profile_path.join("user.js");
let mut prefs = String::new();
// Preserve existing user.js content (ephemeral prefs, etc.)
if let Ok(existing) = std::fs::read_to_string(&user_js_path) {
// Strip old proxy prefs so we don't duplicate
for line in existing.lines() {
if !line.contains("network.proxy.") {
prefs.push_str(line);
prefs.push('\n');
}
}
}
if let Ok(parsed) = url::Url::parse(proxy_str) {
let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port().unwrap_or(8080);
let scheme = parsed.scheme();
if scheme == "socks5" || scheme == "socks4" {
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.socks\", \"{host}\");\n\
user_pref(\"network.proxy.socks_port\", {port});\n\
user_pref(\"network.proxy.socks_version\", {});\n\
user_pref(\"network.proxy.socks_remote_dns\", true);\n",
if scheme == "socks5" { 5 } else { 4 }
));
} else {
// HTTP/HTTPS proxy
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.http\", \"{host}\");\n\
user_pref(\"network.proxy.http_port\", {port});\n\
user_pref(\"network.proxy.ssl\", \"{host}\");\n\
user_pref(\"network.proxy.ssl_port\", {port});\n\
user_pref(\"network.proxy.no_proxies_on\", \"\");\n"
));
}
if let Err(e) = std::fs::write(&user_js_path, prefs) {
log::warn!("Failed to write proxy prefs to user.js: {e}");
}
}
}
self
.launch_camoufox(
&app_handle,
+27 -8
View File
@@ -198,11 +198,20 @@ impl CookieManager {
match profile.browser.as_str() {
"wayfern" => {
let path = profile_data_path.join("Default").join("Cookies");
if path.exists() {
Ok(path)
let network_path = profile_data_path
.join("Default")
.join("Network")
.join("Cookies");
let legacy_path = profile_data_path.join("Default").join("Cookies");
if network_path.exists() {
Ok(network_path)
} else if legacy_path.exists() {
Ok(legacy_path)
} else {
Err(format!("Cookie database not found at: {}", path.display()))
Err(format!(
"Cookie database not found at: {}",
network_path.display()
))
}
}
"camoufox" => {
@@ -232,11 +241,21 @@ impl CookieManager {
match profile.browser.as_str() {
"wayfern" => {
let path = profile_data_path.join("Default").join("Cookies");
if !path.exists() {
Self::create_empty_chrome_cookies_db(&path)?;
let network_path = profile_data_path
.join("Default")
.join("Network")
.join("Cookies");
let legacy_path = profile_data_path.join("Default").join("Cookies");
if network_path.exists() {
Ok(network_path)
} else if legacy_path.exists() {
Ok(legacy_path)
} else {
let dir = network_path.parent().unwrap();
std::fs::create_dir_all(dir).map_err(|e| format!("Failed to create Network dir: {e}"))?;
Self::create_empty_chrome_cookies_db(&network_path)?;
Ok(network_path)
}
Ok(path)
}
"camoufox" => {
let path = profile_data_path.join("cookies.sqlite");
+1 -1
View File
@@ -362,7 +362,7 @@ impl ExtensionManager {
}
}
extensions.sort_by(|a, b| a.created_at.cmp(&b.created_at));
extensions.sort_by_key(|a| a.created_at);
Ok(extensions)
}
+118 -4
View File
@@ -1416,6 +1416,88 @@ pub fn run() {
}
}
// Kill orphaned proxy and VPN worker processes from previous app runs.
// Since active_proxies is an in-memory map that starts empty, any running
// donut-proxy workers on disk must be orphans the current app can't track.
// Without this cleanup, users on Windows accumulate dozens of idle workers
// (one per profile launch) that the periodic cleanup won't touch because
// profile-associated workers are deliberately skipped to avoid regressions.
//
// Preserves workers whose associated profile still has a running browser
// process — if the app crashed while a browser was running, its detached
// browser keeps going and needs the proxy/VPN worker to stay alive.
tauri::async_runtime::spawn(async move {
use crate::proxy_storage::{delete_proxy_config, is_process_running, list_proxy_configs};
use crate::vpn_worker_storage::{delete_vpn_worker_config, list_vpn_worker_configs};
// Build sets of (profile_id, vpn_id) whose browsers are still running
let profile_manager = crate::profile::ProfileManager::instance();
let profiles = profile_manager.list_profiles().unwrap_or_default();
let running_profile_ids: std::collections::HashSet<String> = profiles
.iter()
.filter(|p| p.process_id.is_some_and(is_process_running))
.map(|p| p.id.to_string())
.collect();
let running_vpn_ids: std::collections::HashSet<String> = profiles
.iter()
.filter(|p| p.process_id.is_some_and(is_process_running))
.filter_map(|p| p.vpn_id.clone())
.collect();
for config in list_proxy_configs() {
let has_running_browser = config
.profile_id
.as_ref()
.is_some_and(|pid| running_profile_ids.contains(pid));
if has_running_browser {
log::info!(
"Startup: preserving proxy worker {} (profile browser still running)",
config.id
);
continue;
}
if let Some(pid) = config.pid {
if is_process_running(pid) {
log::info!(
"Startup: killing orphaned proxy worker {} (PID {})",
config.id,
pid
);
let _ = crate::proxy_runner::stop_proxy_process(&config.id).await;
continue;
}
}
delete_proxy_config(&config.id);
}
for worker in list_vpn_worker_configs() {
if running_vpn_ids.contains(&worker.vpn_id) {
log::info!(
"Startup: preserving VPN worker {} (profile browser using vpn_id {} still running)",
worker.id,
worker.vpn_id
);
continue;
}
if let Some(pid) = worker.pid {
if is_process_running(pid) {
log::info!(
"Startup: killing orphaned VPN worker {} (PID {})",
worker.id,
pid
);
let _ = crate::vpn_worker_runner::stop_vpn_worker(&worker.id).await;
continue;
}
}
delete_vpn_worker_config(&worker.id);
}
});
// Immediately bump non-running profiles to the latest installed browser version.
// This runs synchronously before any network calls so profiles are updated on launch.
{
@@ -1537,7 +1619,7 @@ pub fn run() {
let _app_handle_cleanup = app.handle().clone();
tauri::async_runtime::spawn(async move {
let camoufox_manager = crate::camoufox_manager::CamoufoxManager::instance();
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(60));
loop {
interval.tick().await;
@@ -1611,19 +1693,27 @@ pub fn run() {
}
});
// Periodically broadcast browser running status to the frontend
// Periodically broadcast browser running status to the frontend.
// When no profiles have stored PIDs (nothing was ever launched this
// session), we use a long interval (30s) to avoid burning CPU on
// full process-table scans via sysinfo. Once any profile is running
// we switch to the fast interval (5s) for responsive UI updates.
let app_handle_status = app.handle().clone();
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
const FAST_INTERVAL_SECS: u64 = 5;
const IDLE_INTERVAL_SECS: u64 = 30;
let mut interval =
tokio::time::interval(tokio::time::Duration::from_secs(FAST_INTERVAL_SECS));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut last_running_states: std::collections::HashMap<String, bool> =
std::collections::HashMap::new();
let mut current_interval_secs = FAST_INTERVAL_SECS;
loop {
interval.tick().await;
let runner = crate::browser_runner::BrowserRunner::instance();
// If listing profiles fails, skip this tick
let profiles = match runner.profile_manager.list_profiles() {
Ok(p) => p,
Err(e) => {
@@ -1632,6 +1722,30 @@ pub fn run() {
}
};
// If no profile has a stored PID and we have no previously-known
// running states, there's nothing to check — skip the expensive
// process scan entirely.
let any_has_pid = profiles.iter().any(|p| p.process_id.is_some());
let any_was_running = last_running_states.values().any(|&v| v);
if !any_has_pid && !any_was_running {
// Switch to the idle interval to reduce CPU
if current_interval_secs != IDLE_INTERVAL_SECS {
current_interval_secs = IDLE_INTERVAL_SECS;
interval =
tokio::time::interval(tokio::time::Duration::from_secs(IDLE_INTERVAL_SECS));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
}
continue;
}
// At least one profile might be running — use the fast interval
if current_interval_secs != FAST_INTERVAL_SECS {
current_interval_secs = FAST_INTERVAL_SECS;
interval = tokio::time::interval(tokio::time::Duration::from_secs(FAST_INTERVAL_SECS));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
}
for profile in profiles {
// Check browser status and track changes
match runner
+139 -2
View File
@@ -1005,7 +1005,19 @@ impl ProxyManager {
Ok(proxy_config) => {
let local_url = format!("http://127.0.0.1:{}", proxy_config.local_port.unwrap_or(0));
let config_id = proxy_config.id.clone();
let result = ip_utils::fetch_public_ip(Some(&local_url)).await;
// Wrap in a timeout so the check worker doesn't stay alive indefinitely
// if the upstream is slow or unreachable.
let result = tokio::time::timeout(
std::time::Duration::from_secs(30),
ip_utils::fetch_public_ip(Some(&local_url)),
)
.await
.unwrap_or_else(|_| {
Err(ip_utils::IpError::Network(
"Proxy check timed out after 30s".to_string(),
))
});
// Always stop the worker — even if the check failed or timed out
let _ = crate::proxy_runner::stop_proxy_process(&config_id).await;
result
}
@@ -1356,6 +1368,10 @@ impl ProxyManager {
("socks5", rest)
} else if let Some(rest) = line.strip_prefix("socks://") {
("socks5", rest) // Default socks to socks5
} else if let Some(rest) = line.strip_prefix("ss://") {
("ss", rest)
} else if let Some(rest) = line.strip_prefix("shadowsocks://") {
("ss", rest)
} else {
return None;
};
@@ -1996,11 +2012,132 @@ impl ProxyManager {
"Cleaning up orphaned proxy config: {} (proxy process is dead)",
config.id
);
// Just delete the config file - the process is already dead
use crate::proxy_storage::delete_proxy_config;
delete_proxy_config(&config.id);
}
// Kill stale profileless proxy workers — these are check workers
// (from check_proxy_validity or similar) that were never cleaned up.
// Profile-associated proxies are left alone to avoid the regression
// where killing proxies for "dead" browsers on Linux also killed
// proxies for running browsers (due to launcher-vs-browser PID mismatch).
{
use crate::proxy_storage::{is_process_running, list_proxy_configs};
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let all_configs = list_proxy_configs();
for config in all_configs {
// Only target proxies WITHOUT a profile_id (check workers)
if config.profile_id.is_some() {
continue;
}
// Must have a running process to kill
let Some(pid) = config.pid else { continue };
if !is_process_running(pid) {
continue;
}
// Check age: only kill if older than 5 minutes
let proxy_age = config
.id
.strip_prefix("proxy_")
.and_then(|s| s.split('_').next())
.and_then(|s| s.parse::<u64>().ok())
.map(|created_at| now.saturating_sub(created_at))
.unwrap_or(0);
if proxy_age > 300 {
log::info!(
"Killing stale profileless proxy {} (PID {}, age {}s)",
config.id,
pid,
proxy_age
);
let _ = crate::proxy_runner::stop_proxy_process(&config.id).await;
}
}
}
// Kill proxy workers whose browser process has died.
//
// active_proxies is keyed by the EXACT browser PID that was recorded in
// update_proxy_pid(). Checking that PID against a single process-table
// snapshot is deterministic: either the PID refers to a live process or
// it doesn't. This avoids the fuzzy launcher-vs-browser detection used
// by check_browser_status (which historically had false negatives on
// Linux and was the reason profile-associated workers were left alone
// in the other cleanup branches).
//
// Without this, every time a user closes their browser via the window's
// X button (bypassing Donut's stop flow) or the browser crashes, the
// worker keeps running forever. On Windows users reported dozens of
// donut-proxy processes accumulating this way.
{
// Snapshot current active entries first so we don't hold the mutex
// while running the (expensive on Windows) sysinfo scan.
let snapshot: Vec<(u32, String, Option<String>)> = {
let proxies = self.active_proxies.lock().unwrap();
proxies
.iter()
.map(|(&browser_pid, info)| (browser_pid, info.id.clone(), info.profile_id.clone()))
.collect()
};
if !snapshot.is_empty() {
// One process-table scan for all candidates
let system = sysinfo::System::new_with_specifics(
sysinfo::RefreshKind::nothing().with_processes(sysinfo::ProcessRefreshKind::everything()),
);
let dead_browser_entries: Vec<(u32, String, Option<String>)> = snapshot
.into_iter()
.filter(|(browser_pid, _, _)| {
// The sentinel PID=0 is used as a placeholder during launch,
// before update_proxy_pid has recorded the real browser PID.
*browser_pid != 0
&& system
.process(sysinfo::Pid::from_u32(*browser_pid))
.is_none()
})
.collect();
for (browser_pid, proxy_id, profile_id) in dead_browser_entries {
log::info!(
"Cleanup: browser PID {} is dead, stopping proxy worker {} (profile={:?})",
browser_pid,
proxy_id,
profile_id
);
{
let mut proxies = self.active_proxies.lock().unwrap();
// Re-check the entry still maps to the same proxy_id — another
// thread may have replaced it with a new proxy since we snapshotted.
if let Some(current) = proxies.get(&browser_pid) {
if current.id != proxy_id {
continue;
}
} else {
continue;
}
proxies.remove(&browser_pid);
}
if let Some(ref pid) = profile_id {
let mut map = self.profile_active_proxy_ids.lock().unwrap();
if map.get(pid) == Some(&proxy_id) {
map.remove(pid);
}
}
let _ = crate::proxy_runner::stop_proxy_process(&proxy_id).await;
}
}
}
// Clean up orphaned VPN worker configs where the worker process is dead
{
use crate::proxy_storage::is_process_running;
+210 -26
View File
@@ -18,6 +18,13 @@ use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
use tokio::net::TcpListener;
use tokio::net::TcpStream;
/// Combined read+write trait for tunnel target streams, allowing
/// `handle_connect_from_buffer` to handle plain TCP, SOCKS, and
/// Shadowsocks through the same bidirectional-copy path.
trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsyncStream for T {}
type BoxedAsyncStream = Box<dyn AsyncStream>;
use url::Url;
enum CompiledRule {
@@ -770,6 +777,127 @@ async fn handle_http_via_socks4(
Ok(hyper_response)
}
/// Handle plain HTTP requests through a Shadowsocks upstream.
/// reqwest doesn't support SS natively, so we connect through the SS tunnel
/// manually and forward the HTTP request/response.
async fn handle_http_via_shadowsocks(
req: Request<hyper::body::Incoming>,
upstream: &Url,
) -> Result<Response<Full<Bytes>>, Infallible> {
let domain = req
.uri()
.host()
.map(|h| h.to_string())
.unwrap_or_else(|| "unknown".to_string());
let port = req.uri().port_u16().unwrap_or(80);
let ss_host = upstream.host_str().unwrap_or("127.0.0.1");
let ss_port = upstream.port().unwrap_or(8388);
let method_str = urlencoding::decode(upstream.username())
.unwrap_or_default()
.to_string();
let password = urlencoding::decode(upstream.password().unwrap_or(""))
.unwrap_or_default()
.to_string();
let cipher = match method_str.parse::<shadowsocks::crypto::CipherKind>() {
Ok(c) => c,
Err(_) => {
let mut resp = Response::new(Full::new(Bytes::from(format!(
"Bad SS cipher: {method_str}"
))));
*resp.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(resp);
}
};
let context = shadowsocks::context::Context::new_shared(shadowsocks::config::ServerType::Local);
let svr_cfg = match shadowsocks::config::ServerConfig::new(
shadowsocks::config::ServerAddr::from((ss_host.to_string(), ss_port)),
&password,
cipher,
) {
Ok(c) => c,
Err(e) => {
let mut resp = Response::new(Full::new(Bytes::from(format!("SS config error: {e}"))));
*resp.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(resp);
}
};
let target_addr = shadowsocks::relay::Address::DomainNameAddress(domain.clone(), port);
let mut stream = match shadowsocks::relay::tcprelay::proxy_stream::ProxyClientStream::connect(
context,
&svr_cfg,
target_addr,
)
.await
{
Ok(s) => s,
Err(e) => {
let mut resp = Response::new(Full::new(Bytes::from(format!("SS connect: {e}"))));
*resp.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(resp);
}
};
// Build and send the HTTP request through the SS tunnel
let path = req
.uri()
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or("/");
let method = req.method().as_str();
let mut raw_req = format!("{method} {path} HTTP/1.1\r\nHost: {domain}\r\nConnection: close\r\n");
for (name, value) in req.headers() {
if name != "host" && name != "connection" {
raw_req.push_str(&format!("{}: {}\r\n", name, value.to_str().unwrap_or("")));
}
}
raw_req.push_str("\r\n");
use tokio::io::{AsyncReadExt, AsyncWriteExt};
if let Err(e) = stream.write_all(raw_req.as_bytes()).await {
let mut resp = Response::new(Full::new(Bytes::from(format!("SS write: {e}"))));
*resp.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(resp);
}
let mut response_buf = Vec::new();
if let Err(e) = stream.read_to_end(&mut response_buf).await {
log::warn!("SS read error (may be partial): {e}");
}
if let Some(tracker) = get_traffic_tracker() {
tracker.record_request(&domain, raw_req.len() as u64, response_buf.len() as u64);
}
// Parse the raw HTTP response
let response_str = String::from_utf8_lossy(&response_buf);
let header_end = response_str.find("\r\n\r\n").unwrap_or(response_str.len());
let status_line = response_str
.lines()
.next()
.unwrap_or("HTTP/1.1 502 Bad Gateway");
let status_code: u16 = status_line
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(502);
let body = if header_end + 4 < response_buf.len() {
&response_buf[header_end + 4..]
} else {
b""
};
let mut hyper_response = Response::new(Full::new(Bytes::from(body.to_vec())));
*hyper_response.status_mut() =
StatusCode::from_u16(status_code).unwrap_or(StatusCode::BAD_GATEWAY);
Ok(hyper_response)
}
async fn handle_http(
req: Request<hyper::body::Incoming>,
upstream_url: Option<String>,
@@ -800,14 +928,19 @@ async fn handle_http(
let should_bypass = bypass_matcher.should_bypass(&domain);
// Check if we need to handle SOCKS4 manually (reqwest doesn't support it)
// Handle proxy types that reqwest doesn't support natively
if !should_bypass {
if let Some(ref upstream) = upstream_url {
if upstream != "DIRECT" {
if let Ok(url) = Url::parse(upstream) {
if url.scheme() == "socks4" {
// Handle SOCKS4 manually for HTTP requests
return handle_http_via_socks4(req, upstream).await;
match url.scheme() {
"socks4" => {
return handle_http_via_socks4(req, upstream).await;
}
"ss" | "shadowsocks" => {
return handle_http_via_shadowsocks(req, &url).await;
}
_ => {}
}
}
}
@@ -1298,37 +1431,41 @@ async fn handle_connect_from_buffer(
tracker.record_request(&domain, 0, 0);
}
// Connect to target (directly or via upstream proxy)
// Connect to target (directly or via upstream proxy).
// Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS,
// Shadowsocks) share the same bidirectional-copy tunnel code below.
let should_bypass = bypass_matcher.should_bypass(target_host);
let target_stream = match upstream_url.as_ref() {
// Helper: configure outbound TCP to match browser TCP fingerprint
let configure_tcp = |stream: &TcpStream| {
let _ = stream.set_nodelay(true);
};
let target_stream: BoxedAsyncStream = match upstream_url.as_ref() {
None => {
// Direct connection
TcpStream::connect((target_host, target_port)).await?
let s = TcpStream::connect((target_host, target_port)).await?;
configure_tcp(&s);
Box::new(s)
}
Some(url) if url == "DIRECT" => {
// Direct connection
TcpStream::connect((target_host, target_port)).await?
let s = TcpStream::connect((target_host, target_port)).await?;
configure_tcp(&s);
Box::new(s)
}
_ if should_bypass => {
// Bypass rule matched - connect directly
TcpStream::connect((target_host, target_port)).await?
let s = TcpStream::connect((target_host, target_port)).await?;
configure_tcp(&s);
Box::new(s)
}
Some(upstream_url_str) => {
// Connect via upstream proxy
let upstream = Url::parse(upstream_url_str)?;
let scheme = upstream.scheme();
match scheme {
"http" | "https" => {
// Connect via HTTP/HTTPS proxy CONNECT
// Note: HTTPS proxy URLs still use HTTP CONNECT method (CONNECT is always HTTP-based)
// For HTTPS proxies, reqwest handles TLS automatically in handle_http
// For manual CONNECT here, we use plain TCP - HTTPS proxy CONNECT typically works over plain TCP
let proxy_host = upstream.host_str().unwrap_or("127.0.0.1");
let proxy_port = upstream.port().unwrap_or(8080);
let mut proxy_stream = TcpStream::connect((proxy_host, proxy_port)).await?;
configure_tcp(&proxy_stream);
// Add authentication if provided
let mut connect_req = format!(
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
target_host, target_port, target_host, target_port
@@ -1344,10 +1481,8 @@ async fn handle_connect_from_buffer(
connect_req.push_str("\r\n");
// Send CONNECT request to upstream proxy
proxy_stream.write_all(connect_req.as_bytes()).await?;
// Read response
let mut buffer = [0u8; 4096];
let n = proxy_stream.read(&mut buffer).await?;
let response = String::from_utf8_lossy(&buffer[..n]);
@@ -1356,10 +1491,9 @@ async fn handle_connect_from_buffer(
return Err(format!("Upstream proxy CONNECT failed: {}", response).into());
}
proxy_stream
Box::new(proxy_stream)
}
"socks4" | "socks5" => {
// Connect via SOCKS proxy
let socks_host = upstream.host_str().unwrap_or("127.0.0.1");
let socks_port = upstream.port().unwrap_or(1080);
let socks_addr = format!("{}:{}", socks_host, socks_port);
@@ -1367,7 +1501,7 @@ async fn handle_connect_from_buffer(
let username = upstream.username();
let password = upstream.password().unwrap_or("");
connect_via_socks(
let stream = connect_via_socks(
&socks_addr,
target_host,
target_port,
@@ -1378,7 +1512,56 @@ async fn handle_connect_from_buffer(
None
},
)
.await?
.await?;
Box::new(stream)
}
"ss" | "shadowsocks" => {
// Shadowsocks: URL format is ss://method:password@host:port
// where "method" is the cipher (e.g. aes-256-gcm, chacha20-ietf-poly1305)
// and "password" is the SS server password.
let ss_host = upstream.host_str().unwrap_or("127.0.0.1");
let ss_port = upstream.port().unwrap_or(8388);
// The "username" field carries the cipher method
let method_str = urlencoding::decode(upstream.username())
.unwrap_or_default()
.to_string();
let password = urlencoding::decode(upstream.password().unwrap_or(""))
.unwrap_or_default()
.to_string();
if method_str.is_empty() || password.is_empty() {
return Err(
"Shadowsocks requires method and password (URL: ss://method:password@host:port)"
.into(),
);
}
let cipher = method_str.parse::<shadowsocks::crypto::CipherKind>().map_err(|_| {
format!("Unsupported Shadowsocks cipher: {method_str}. Use e.g. aes-256-gcm, chacha20-ietf-poly1305, aes-128-gcm")
})?;
let context =
shadowsocks::context::Context::new_shared(shadowsocks::config::ServerType::Local);
let svr_cfg = shadowsocks::config::ServerConfig::new(
shadowsocks::config::ServerAddr::from((ss_host.to_string(), ss_port)),
&password,
cipher,
)
.map_err(|e| format!("Invalid Shadowsocks config: {e}"))?;
let target_addr =
shadowsocks::relay::Address::DomainNameAddress(target_host.to_string(), target_port);
let stream = shadowsocks::relay::tcprelay::proxy_stream::ProxyClientStream::connect(
context,
&svr_cfg,
target_addr,
)
.await
.map_err(|e| format!("Shadowsocks connection failed: {e}"))?;
Box::new(stream)
}
_ => {
return Err(format!("Unsupported upstream proxy scheme: {}", scheme).into());
@@ -1387,8 +1570,9 @@ async fn handle_connect_from_buffer(
}
};
// Enable TCP_NODELAY on target stream for immediate data transfer
let _ = target_stream.set_nodelay(true);
// TCP_NODELAY is set per-stream where applicable (TcpStream paths).
// For encrypted streams (Shadowsocks), the underlying TCP connection
// is managed by the library and nodelay is handled internally.
// Send 200 Connection Established response to client
// CRITICAL: Must flush after writing to ensure response is sent before tunneling
+13 -6
View File
@@ -230,11 +230,7 @@ impl SyncProgressTracker {
let elapsed = self.start_time.elapsed().as_secs_f64().max(0.1);
let speed = (completed_bytes as f64 / elapsed) as u64;
let remaining_bytes = self.total_bytes.saturating_sub(completed_bytes);
let eta = if speed > 0 {
remaining_bytes / speed
} else {
0
};
let eta = remaining_bytes.checked_div(speed).unwrap_or(0);
let _ = events::emit(
"profile-sync-progress",
@@ -2344,7 +2340,18 @@ impl SyncEngine {
// Verify critical files after download
let os_crypt_key_path = profile_dir.join("profile").join("os_crypt_key");
let cookies_path = profile_dir.join("profile").join("Default").join("Cookies");
let cookies_path = {
let network = profile_dir
.join("profile")
.join("Default")
.join("Network")
.join("Cookies");
if network.exists() {
network
} else {
profile_dir.join("profile").join("Default").join("Cookies")
}
};
if os_crypt_key_path.exists() {
let key_data = fs::read(&os_crypt_key_path).unwrap_or_default();
log::info!(
+1 -1
View File
@@ -344,7 +344,7 @@ impl SyncScheduler {
}
}
}
_ = sleep(Duration::from_millis(500)) => {
_ = sleep(Duration::from_millis(2000)) => {
scheduler.process_pending(&app_handle_clone).await;
}
}
+57 -26
View File
@@ -141,11 +141,15 @@ pub fn parse_wireguard_config(content: &str) -> Result<WireGuardConfig, VpnError
let mut peer: HashMap<String, String> = HashMap::new();
let mut current_section: Option<&str> = None;
// Strip a UTF-8 BOM if present — some editors/tools emit one and it would
// otherwise prepend invisible bytes to the first section header
let content = content.strip_prefix('\u{feff}').unwrap_or(content);
for line in content.lines() {
let line = line.trim();
// Skip empty lines and comments
if line.is_empty() || line.starts_with('#') {
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
@@ -159,7 +163,7 @@ pub fn parse_wireguard_config(content: &str) -> Result<WireGuardConfig, VpnError
continue;
}
// Parse key-value pairs
// Parse key-value pairs (split on the first `=` so base64 padding is preserved)
if let Some((key, value)) = line.split_once('=') {
let key = key.trim().to_string();
let value = value.trim().to_string();
@@ -181,6 +185,7 @@ pub fn parse_wireguard_config(content: &str) -> Result<WireGuardConfig, VpnError
.get("PrivateKey")
.ok_or_else(|| VpnError::InvalidWireGuard("Missing PrivateKey in [Interface]".to_string()))?
.clone();
validate_wireguard_key(&private_key, "PrivateKey")?;
let address = interface
.get("Address")
@@ -191,6 +196,7 @@ pub fn parse_wireguard_config(content: &str) -> Result<WireGuardConfig, VpnError
.get("PublicKey")
.ok_or_else(|| VpnError::InvalidWireGuard("Missing PublicKey in [Peer]".to_string()))?
.clone();
validate_wireguard_key(&peer_public_key, "PublicKey")?;
let peer_endpoint = peer
.get("Endpoint")
@@ -207,6 +213,9 @@ pub fn parse_wireguard_config(content: &str) -> Result<WireGuardConfig, VpnError
let dns = interface.get("DNS").cloned();
let mtu = interface.get("MTU").and_then(|s| s.parse().ok());
let preshared_key = peer.get("PresharedKey").cloned();
if let Some(ref psk) = preshared_key {
validate_wireguard_key(psk, "PresharedKey")?;
}
Ok(WireGuardConfig {
private_key,
@@ -221,6 +230,30 @@ pub fn parse_wireguard_config(content: &str) -> Result<WireGuardConfig, VpnError
})
}
/// Validate that a WireGuard key is a base64-encoded 32-byte value.
/// Reports the field name and a short preview of the bad value so users can
/// see exactly what went wrong (e.g. a redacted/masked key).
fn validate_wireguard_key(key: &str, field: &str) -> Result<(), VpnError> {
use base64::Engine;
let decoded = base64::engine::general_purpose::STANDARD
.decode(key)
.map_err(|e| {
let preview: String = key.chars().take(8).collect();
VpnError::InvalidWireGuard(format!(
"{field} is not valid base64 (starts with {preview:?}): {e}. \
Expected a 32-byte base64-encoded key (44 chars ending with '=')."
))
})?;
if decoded.len() != 32 {
return Err(VpnError::InvalidWireGuard(format!(
"{field} decoded to {} bytes (expected 32). The config may be truncated or malformed.",
decoded.len()
)));
}
Ok(())
}
/// Parse an OpenVPN configuration file
pub fn parse_openvpn_config(content: &str) -> Result<OpenVpnConfig, VpnError> {
let mut remote_host = String::new();
@@ -250,31 +283,23 @@ pub fn parse_openvpn_config(content: &str) -> Result<OpenVpnConfig, VpnError> {
if parts.len() >= 2 {
remote_host = parts[1].to_string();
}
if parts.len() >= 3 {
if let Ok(port) = parts[2].parse() {
remote_port = port;
}
if let Some(port) = parts.get(2).and_then(|p| p.parse().ok()) {
remote_port = port;
}
if parts.len() >= 4 {
protocol = parts[3].to_string();
}
}
"proto" => {
if parts.len() >= 2 {
protocol = parts[1].to_string();
}
"proto" if parts.len() >= 2 => {
protocol = parts[1].to_string();
}
"port" => {
if parts.len() >= 2 {
if let Ok(port) = parts[1].parse() {
remote_port = port;
}
if let Some(port) = parts.get(1).and_then(|p| p.parse().ok()) {
remote_port = port;
}
}
"dev" => {
if parts.len() >= 2 {
dev_type = parts[1].to_string();
}
"dev" if parts.len() >= 2 => {
dev_type = parts[1].to_string();
}
_ => {}
}
@@ -348,13 +373,13 @@ mod tests {
fn test_parse_wireguard_config() {
let content = r#"
[Interface]
PrivateKey = WGTestPrivateKey123456789012345678901234567890
PrivateKey = YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=
Address = 10.0.0.2/24
DNS = 1.1.1.1
MTU = 1420
[Peer]
PublicKey = WGTestPublicKey1234567890123456789012345678901
PublicKey = YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI=
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
@@ -363,14 +388,14 @@ PersistentKeepalive = 25
let config = parse_wireguard_config(content).unwrap();
assert_eq!(
config.private_key,
"WGTestPrivateKey123456789012345678901234567890"
"YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="
);
assert_eq!(config.address, "10.0.0.2/24");
assert_eq!(config.dns, Some("1.1.1.1".to_string()));
assert_eq!(config.mtu, Some(1420));
assert_eq!(
config.peer_public_key,
"WGTestPublicKey1234567890123456789012345678901"
"YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI="
);
assert_eq!(config.peer_endpoint, "vpn.example.com:51820");
assert_eq!(config.allowed_ips, vec!["0.0.0.0/0", "::/0"]);
@@ -381,20 +406,26 @@ PersistentKeepalive = 25
fn test_parse_wireguard_config_minimal() {
let content = r#"
[Interface]
PrivateKey = minimalkey
PrivateKey = YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=
Address = 10.0.0.2/32
[Peer]
PublicKey = peerpubkey
PublicKey = YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI=
Endpoint = 1.2.3.4:51820
"#;
let config = parse_wireguard_config(content).unwrap();
assert_eq!(config.private_key, "minimalkey");
assert_eq!(
config.private_key,
"YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="
);
assert_eq!(config.address, "10.0.0.2/32");
assert!(config.dns.is_none());
assert!(config.mtu.is_none());
assert_eq!(config.peer_public_key, "peerpubkey");
assert_eq!(
config.peer_public_key,
"YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI="
);
assert_eq!(config.peer_endpoint, "1.2.3.4:51820");
}
+4 -6
View File
@@ -622,12 +622,10 @@ impl WireGuardSocks5Server {
// 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;
}
Ok(data) if !data.is_empty() && conn.tcp_stream.try_write(&data).is_err() => {
socket.close();
completed.push(idx);
continue;
}
_ => {}
}
+10 -2
View File
@@ -2,7 +2,8 @@ use crate::proxy_runner::find_sidecar_executable;
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,
get_vpn_worker_config, list_vpn_worker_configs, save_vpn_worker_config, vpn_worker_config_path,
VpnWorkerConfig,
};
use std::process::Stdio;
@@ -175,6 +176,8 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
);
save_vpn_worker_config(&config)?;
let config_json_path = vpn_worker_config_path(&id);
// Spawn detached VPN worker process
let exe = find_sidecar_executable("donut-proxy")?;
@@ -190,6 +193,8 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
cmd.arg(&id);
cmd.arg("--port");
cmd.arg(local_port.to_string());
cmd.arg("--config-path");
cmd.arg(&config_json_path);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
@@ -235,6 +240,8 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
cmd.arg(&id);
cmd.arg("--port");
cmd.arg(local_port.to_string());
cmd.arg("--config-path");
cmd.arg(&config_json_path);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
@@ -249,7 +256,8 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW);
let child = cmd.spawn()?;
let pid = child.id();
+4
View File
@@ -27,6 +27,10 @@ impl VpnWorkerConfig {
}
}
pub fn vpn_worker_config_path(id: &str) -> std::path::PathBuf {
get_storage_dir().join(format!("vpn_worker_{}.json", id))
}
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)?;
+149 -63
View File
@@ -1,5 +1,6 @@
use crate::browser_runner::BrowserRunner;
use crate::profile::BrowserProfile;
use playwright::api::Playwright;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -53,14 +54,14 @@ pub struct WayfernLaunchResult {
pub cdp_port: Option<u16>,
}
#[derive(Debug)]
struct WayfernInstance {
#[allow(dead_code)]
id: String,
process_id: Option<u32>,
profile_path: Option<String>,
url: Option<String>,
cdp_port: Option<u16>,
playwright_context: Option<playwright::api::BrowserContext>,
playwright_runtime: Option<Playwright>,
}
struct WayfernManagerInner {
@@ -86,14 +87,6 @@ impl WayfernManager {
inner: Arc::new(AsyncMutex::new(WayfernManagerInner {
instances: HashMap::new(),
})),
// Every request this client makes goes to a local `http://127.0.0.1:<port>`
// endpoint that Wayfern is still bringing up. Without a per-request timeout,
// a single hanging connect or a stuck HTTP response will block
// `wait_for_cdp_ready` indefinitely — its 120-attempt poll loop depends on
// each request returning fast. A 2-second per-request timeout turns that
// into a worst-case ~60-second bounded wait, and `generate_fingerprint_config`
// can then return a real error instead of hanging the profile-creation UI
// forever.
http_client: Client::builder()
.timeout(Duration::from_secs(2))
.build()
@@ -101,6 +94,16 @@ impl WayfernManager {
}
}
async fn create_playwright(
&self,
) -> Result<Playwright, Box<dyn std::error::Error + Send + Sync>> {
Playwright::initialize()
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to initialize Playwright: {e}").into()
})
}
pub fn instance() -> &'static WayfernManager {
&WAYFERN_MANAGER
}
@@ -264,9 +267,18 @@ impl WayfernManager {
.arg("--disable-setuid-sandbox")
.arg("--disable-dev-shm-usage");
cmd.stdout(Stdio::null()).stderr(Stdio::null());
cmd.stdout(Stdio::null()).stderr(Stdio::piped());
let child = cmd.spawn()?;
let child = cmd.spawn().map_err(|e| {
// OS error 14001 = SxS / missing Visual C++ Redistributable
let hint = if e.raw_os_error() == Some(14001) {
". This usually means the Visual C++ Redistributable is not installed. \
Download it from https://aka.ms/vs/17/release/vc_redist.x64.exe"
} else {
""
};
format!("Failed to spawn headless Wayfern: {e}{hint}")
})?;
let child_id = child.id();
let cleanup = || async {
@@ -291,8 +303,27 @@ impl WayfernManager {
};
if let Err(e) = self.wait_for_cdp_ready(port).await {
// Try to capture stderr from the failed process for diagnostics
let stderr_output = if let Some(id) = child_id {
// Check if process is still running
let is_running = sysinfo::System::new_with_specifics(
sysinfo::RefreshKind::nothing().with_processes(sysinfo::ProcessRefreshKind::nothing()),
)
.process(sysinfo::Pid::from(id as usize))
.is_some();
if !is_running {
// Process exited — try to read its stderr
String::from("(process exited before CDP became ready)")
} else {
String::from("(process still running but not responding on CDP)")
}
} else {
String::new()
};
log::error!(
"Fingerprint-generation Wayfern (headless, pid={child_id:?}) never became CDP-ready: {e}"
"Fingerprint-generation Wayfern (headless, pid={child_id:?}) never became CDP-ready: {e}. {stderr_output}"
);
cleanup().await;
return Err(e);
@@ -488,7 +519,17 @@ impl WayfernManager {
{
let profile_path_buf = std::path::PathBuf::from(profile_path);
let key_path = profile_path_buf.join("os_crypt_key");
let cookies_path = profile_path_buf.join("Default").join("Cookies");
let cookies_path = {
let network = profile_path_buf
.join("Default")
.join("Network")
.join("Cookies");
if network.exists() {
network
} else {
profile_path_buf.join("Default").join("Cookies")
}
};
if key_path.exists() {
let key_text = std::fs::read_to_string(&key_path).unwrap_or_default();
@@ -565,7 +606,6 @@ impl WayfernManager {
let mut args = vec![
format!("--remote-debugging-port={port}"),
"--remote-debugging-address=127.0.0.1".to_string(),
format!("--user-data-dir={}", profile_path),
"--no-first-run".to_string(),
"--no-default-browser-check".to_string(),
"--disable-background-mode".to_string(),
@@ -576,7 +616,7 @@ impl WayfernManager {
"--disable-session-crashed-bubble".to_string(),
"--hide-crash-restore-bubble".to_string(),
"--disable-infobars".to_string(),
"--disable-features=DialMediaRouteProvider".to_string(),
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns".to_string(),
"--use-mock-keychain".to_string(),
"--password-store=basic".to_string(),
];
@@ -588,10 +628,6 @@ impl WayfernManager {
args.push("--disable-dev-shm-usage".to_string());
}
if let Some(proxy) = proxy_url {
args.push(format!("--proxy-server={proxy}"));
}
if ephemeral {
args.push("--disk-cache-size=1".to_string());
args.push("--disable-breakpad".to_string());
@@ -604,8 +640,17 @@ impl WayfernManager {
args.push(format!("--load-extension={}", extension_paths.join(",")));
}
// Pass wayfern token as CLI flag so the browser can gate CDP features
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
let mut wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
if wayfern_token.is_none() {
log::info!("Wayfern token not ready, waiting...");
for _ in 0..15 {
tokio::time::sleep(Duration::from_secs(1)).await;
wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
if wayfern_token.is_some() {
break;
}
}
}
if let Some(ref token) = wayfern_token {
args.push(format!("--wayfern-token={token}"));
log::info!("Wayfern token passed as CLI flag (length: {})", token.len());
@@ -613,20 +658,61 @@ impl WayfernManager {
log::warn!("No wayfern token available — CDP gated methods will be blocked");
}
// Don't add URL to args - we'll navigate via CDP after setting fingerprint
// This ensures fingerprint is applied at navigation commit time
if let Some(proxy) = proxy_url {
let pac_data = format!(
"data:application/x-ns-proxy-autoconfig,function FindProxyForURL(url,host){{return \"PROXY {}\";}}",
proxy.trim_start_matches("http://").trim_start_matches("https://")
);
args.push(format!("--proxy-pac-url={pac_data}"));
args.push("--dns-prefetch-disable".to_string());
}
let mut cmd = TokioCommand::new(&executable_path);
cmd.args(&args);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let pw = self.create_playwright().await?;
let chromium = pw.chromium();
let profile_path_ref = std::path::Path::new(profile_path);
let mut launcher = chromium.persistent_context_launcher(profile_path_ref);
launcher = launcher.executable(executable_path.as_ref());
launcher = launcher.headless(false);
launcher = launcher.chromium_sandbox(true);
launcher = launcher.args(&args);
launcher = launcher.timeout(0.0);
let child = cmd.spawn()?;
let process_id = child.id();
let pw_context =
launcher
.launch()
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
let hint = if format!("{e}").contains("14001") {
". This usually means the Visual C++ Redistributable is not installed. \
Download it from https://aka.ms/vs/17/release/vc_redist.x64.exe"
} else {
""
};
format!("Failed to launch Wayfern: {e}{hint}").into()
})?;
self.wait_for_cdp_ready(port).await?;
let process_id = {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let mut found: Option<u32> = None;
for (pid, process) in system.processes() {
let cmd_str = process
.cmd()
.iter()
.map(|s| s.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(" ");
if cmd_str.contains(&format!("--remote-debugging-port={port}")) {
found = Some(pid.as_u32());
break;
}
}
found
};
let pw_runtime = pw;
// Get CDP targets first - needed for both fingerprint and navigation
let targets = self.get_cdp_targets(port).await?;
log::info!("Found {} CDP targets", targets.len());
@@ -725,37 +811,7 @@ impl WayfernManager {
log::warn!("No fingerprint found in config, browser will use default fingerprint");
}
// Set geolocation override via CDP so navigator.geolocation.getCurrentPosition() matches
if let Some(fingerprint_json) = &config.fingerprint {
if let Ok(fp) = serde_json::from_str::<serde_json::Value>(fingerprint_json) {
let fp_obj = if fp.get("fingerprint").is_some() {
fp.get("fingerprint").unwrap()
} else {
&fp
};
if let (Some(lat), Some(lng)) = (
fp_obj.get("latitude").and_then(|v| v.as_f64()),
fp_obj.get("longitude").and_then(|v| v.as_f64()),
) {
let accuracy = fp_obj
.get("accuracy")
.and_then(|v| v.as_f64())
.unwrap_or(100.0);
if let Some(target) = page_targets.first() {
if let Some(ws_url) = &target.websocket_debugger_url {
let _ = self
.send_cdp_command(
ws_url,
"Emulation.setGeolocationOverride",
json!({ "latitude": lat, "longitude": lng, "accuracy": accuracy }),
)
.await;
log::info!("Set geolocation override: lat={lat}, lng={lng}");
}
}
}
}
}
// Geolocation is handled internally by the browser binary.
// Navigate to URL via CDP - fingerprint will be applied at navigation commit time
if let Some(url) = url {
@@ -773,6 +829,29 @@ impl WayfernManager {
}
}
// Clear Playwright's emulation overrides that cause tampering detection
for target in &page_targets {
if let Some(ws_url) = &target.websocket_debugger_url {
let _ = self
.send_cdp_command(ws_url, "Emulation.clearDeviceMetricsOverride", json!({}))
.await;
let _ = self
.send_cdp_command(
ws_url,
"Emulation.setFocusEmulationEnabled",
json!({ "enabled": false }),
)
.await;
let _ = self
.send_cdp_command(
ws_url,
"Emulation.setEmulatedMedia",
json!({ "media": "", "features": [] }),
)
.await;
}
}
let id = uuid::Uuid::new_v4().to_string();
let instance = WayfernInstance {
id: id.clone(),
@@ -780,6 +859,8 @@ impl WayfernManager {
profile_path: Some(profile_path.to_string()),
url: url.map(|s| s.to_string()),
cdp_port: Some(port),
playwright_context: Some(pw_context),
playwright_runtime: Some(pw_runtime),
};
let mut inner = self.inner.lock().await;
@@ -801,6 +882,9 @@ impl WayfernManager {
let mut inner = self.inner.lock().await;
if let Some(instance) = inner.instances.remove(id) {
log::info!("Cleaning up Wayfern instance {}", instance.id);
drop(instance.playwright_context);
drop(instance.playwright_runtime);
if let Some(pid) = instance.process_id {
#[cfg(unix)]
{
@@ -955,6 +1039,8 @@ impl WayfernManager {
profile_path: Some(found_profile_path.clone()),
url: None,
cdp_port,
playwright_context: None,
playwright_runtime: None,
},
);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.20.4",
"version": "0.21.1",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+131
View File
@@ -1298,3 +1298,134 @@ async fn test_local_proxy_with_socks5_upstream(
Ok(())
}
/// Test proxying traffic through a real Shadowsocks server running in Docker.
/// Verifies the full chain: client → donut-proxy → Shadowsocks → internet.
#[tokio::test]
#[serial]
async fn test_local_proxy_with_shadowsocks_upstream(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
// Check Docker availability
let docker_check = std::process::Command::new("docker").arg("version").output();
if docker_check.map(|o| !o.status.success()).unwrap_or(true) {
eprintln!("skipping Shadowsocks e2e test because Docker is unavailable");
return Ok(());
}
// Start a Shadowsocks server container
let ss_container = "donut-ss-test";
let ss_port = 18388u16;
let ss_password = "donut-test-password";
let ss_method = "aes-256-gcm";
// Clean up any previous container
let _ = std::process::Command::new("docker")
.args(["rm", "-f", ss_container])
.output();
let docker_start = std::process::Command::new("docker")
.args([
"run",
"-d",
"--name",
ss_container,
"-p",
&format!("{ss_port}:8388"),
"ghcr.io/shadowsocks/ssserver-rust:latest",
"ssserver",
"-s",
"[::]:8388",
"-k",
ss_password,
"-m",
ss_method,
])
.output()?;
if !docker_start.status.success() {
let stderr = String::from_utf8_lossy(&docker_start.stderr);
eprintln!("skipping Shadowsocks e2e test: Docker run failed: {stderr}");
return Ok(());
}
// Wait for the SS server to be ready
for _ in 0..15 {
sleep(Duration::from_secs(1)).await;
if TcpStream::connect(("127.0.0.1", ss_port)).await.is_ok() {
break;
}
}
// Start donut-proxy with Shadowsocks upstream
let output = TestUtils::execute_command(
&binary_path,
&[
"proxy",
"start",
"--host",
"127.0.0.1",
"--proxy-port",
&ss_port.to_string(),
"--type",
"ss",
"--username",
ss_method,
"--password",
ss_password,
],
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let _ = std::process::Command::new("docker")
.args(["rm", "-f", ss_container])
.output();
return Err(format!("Proxy start failed: {stderr}").into());
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
let local_port = config["localPort"].as_u64().unwrap() as u16;
tracker.track_proxy(proxy_id);
// Wait for proxy to be fully ready
for _ in 0..20 {
sleep(Duration::from_millis(100)).await;
if TcpStream::connect(("127.0.0.1", local_port)).await.is_ok() {
break;
}
}
sleep(Duration::from_millis(500)).await;
// Test: HTTP request through donut-proxy → Shadowsocks → example.com
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request =
"GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n";
stream.write_all(request.as_bytes()).await?;
let mut response = vec![0u8; 16384];
let n = tokio::time::timeout(Duration::from_secs(15), stream.read(&mut response))
.await
.map_err(|_| "HTTP request through Shadowsocks timed out")?
.map_err(|e| format!("Read error: {e}"))?;
let response_str = String::from_utf8_lossy(&response[..n]);
assert!(
response_str.contains("Example Domain"),
"HTTP traffic through Shadowsocks should reach example.com, got: {}",
&response_str[..response_str.len().min(500)]
);
println!("Shadowsocks upstream proxy test passed");
// Cleanup
tracker.cleanup_all().await;
let _ = std::process::Command::new("docker")
.args(["rm", "-f", ss_container])
.output();
Ok(())
}
+1 -1
View File
@@ -144,7 +144,7 @@ Endpoint = 1.2.3.4:51820
fn test_wireguard_config_missing_peer() {
let config = r#"
[Interface]
PrivateKey = somekey
PrivateKey = YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=
Address = 10.0.0.2/24
"#;
let result = parse_wireguard_config(config);
+32 -8
View File
@@ -94,6 +94,19 @@ export function ProxyFormDialog({
return;
}
if (
form.proxy_type === "ss" &&
(!form.username.trim() || !form.password.trim())
) {
toast.error(
t(
"proxies.form.ssCipherRequired",
"Cipher and password are required for Shadowsocks",
),
);
return;
}
setIsSubmitting(true);
try {
const payload = {
@@ -136,7 +149,12 @@ export function ProxyFormDialog({
}, [isSubmitting, onClose]);
const isFormValid =
form.name.trim() && form.host.trim() && form.port > 0 && form.port <= 65535;
form.name.trim() &&
form.host.trim() &&
form.port > 0 &&
form.port <= 65535 &&
(form.proxy_type !== "ss" ||
(form.username.trim() && form.password.trim()));
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
@@ -174,9 +192,9 @@ export function ProxyFormDialog({
<SelectValue placeholder="Select proxy type" />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5"].map((type) => (
{["http", "https", "socks4", "socks5", "ss"].map((type) => (
<SelectItem key={type} value={type}>
{type.toUpperCase()}
{type === "ss" ? "Shadowsocks" : type.toUpperCase()}
</SelectItem>
))}
</SelectContent>
@@ -220,8 +238,9 @@ export function ProxyFormDialog({
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-username">
{t("proxies.form.username")} (
{t("proxies.form.usernamePlaceholder")})
{form.proxy_type === "ss"
? t("proxies.form.cipher")
: `${t("proxies.form.username")} (${t("proxies.form.usernamePlaceholder")})`}
</Label>
<Input
id="proxy-username"
@@ -229,15 +248,20 @@ export function ProxyFormDialog({
onChange={(e) => {
setForm({ ...form, username: e.target.value });
}}
placeholder={t("proxies.form.usernamePlaceholder")}
placeholder={
form.proxy_type === "ss"
? t("proxies.form.cipherPlaceholder")
: t("proxies.form.usernamePlaceholder")
}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-password">
{t("proxies.form.password")} (
{t("proxies.form.passwordPlaceholder")})
{form.proxy_type === "ss"
? t("proxies.form.password")
: `${t("proxies.form.password")} (${t("proxies.form.passwordPlaceholder")})`}
</Label>
<Input
id="proxy-password"
+6 -1
View File
@@ -67,7 +67,12 @@ const ChartContainer = React.forwardRef<
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer minWidth={1} minHeight={1}>
<RechartsPrimitive.ResponsiveContainer
width="100%"
height="100%"
minWidth={1}
minHeight={1}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
+1 -2
View File
@@ -118,8 +118,7 @@ function RippleButton({
ref={buttonRef}
data-slot="ripple-button"
onClick={handleClick}
whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.97 }}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
>
+6 -2
View File
@@ -145,14 +145,18 @@ export function usePermissions(): UsePermissionsReturn {
initializePlatform();
}, []);
// Set up interval checking when platform is determined
// Set up interval checking when platform is determined.
// On non-macOS platforms, permissions are always granted — a single check
// is enough and we skip the interval entirely to avoid burning CPU.
useEffect(() => {
if (!currentPlatform) return;
// Initial check
void checkPermissions();
// Set up 500ms interval for checking permissions
// Only poll on macOS where permissions can change at runtime
if (currentPlatform !== "macos") return;
intervalRef.current = setInterval(() => {
void checkPermissions();
}, 500);
+5 -2
View File
@@ -278,13 +278,16 @@
"username": "Username",
"usernamePlaceholder": "Optional",
"password": "Password",
"passwordPlaceholder": "Optional"
"passwordPlaceholder": "Optional",
"cipher": "Cipher",
"cipherPlaceholder": "aes-256-gcm"
},
"types": {
"http": "HTTP",
"https": "HTTPS",
"socks4": "SOCKS4",
"socks5": "SOCKS5"
"socks5": "SOCKS5",
"ss": "Shadowsocks"
},
"tabs": {
"regular": "Regular",
+5 -2
View File
@@ -278,13 +278,16 @@
"username": "Usuario",
"usernamePlaceholder": "Opcional",
"password": "Contraseña",
"passwordPlaceholder": "Opcional"
"passwordPlaceholder": "Opcional",
"cipher": "Cifrado",
"cipherPlaceholder": "aes-256-gcm"
},
"types": {
"http": "HTTP",
"https": "HTTPS",
"socks4": "SOCKS4",
"socks5": "SOCKS5"
"socks5": "SOCKS5",
"ss": "Shadowsocks"
},
"tabs": {
"regular": "Regular",
+5 -2
View File
@@ -278,13 +278,16 @@
"username": "Nom d'utilisateur",
"usernamePlaceholder": "Optionnel",
"password": "Mot de passe",
"passwordPlaceholder": "Optionnel"
"passwordPlaceholder": "Optionnel",
"cipher": "Chiffrement",
"cipherPlaceholder": "aes-256-gcm"
},
"types": {
"http": "HTTP",
"https": "HTTPS",
"socks4": "SOCKS4",
"socks5": "SOCKS5"
"socks5": "SOCKS5",
"ss": "Shadowsocks"
},
"tabs": {
"regular": "Standard",
+5 -2
View File
@@ -278,13 +278,16 @@
"username": "ユーザー名",
"usernamePlaceholder": "任意",
"password": "パスワード",
"passwordPlaceholder": "任意"
"passwordPlaceholder": "任意",
"cipher": "暗号方式",
"cipherPlaceholder": "aes-256-gcm"
},
"types": {
"http": "HTTP",
"https": "HTTPS",
"socks4": "SOCKS4",
"socks5": "SOCKS5"
"socks5": "SOCKS5",
"ss": "Shadowsocks"
},
"tabs": {
"regular": "通常",
+5 -2
View File
@@ -278,13 +278,16 @@
"username": "Usuário",
"usernamePlaceholder": "Opcional",
"password": "Senha",
"passwordPlaceholder": "Opcional"
"passwordPlaceholder": "Opcional",
"cipher": "Cifra",
"cipherPlaceholder": "aes-256-gcm"
},
"types": {
"http": "HTTP",
"https": "HTTPS",
"socks4": "SOCKS4",
"socks5": "SOCKS5"
"socks5": "SOCKS5",
"ss": "Shadowsocks"
},
"tabs": {
"regular": "Regular",
+5 -2
View File
@@ -278,13 +278,16 @@
"username": "Имя пользователя",
"usernamePlaceholder": "Необязательно",
"password": "Пароль",
"passwordPlaceholder": "Необязательно"
"passwordPlaceholder": "Необязательно",
"cipher": "Шифр",
"cipherPlaceholder": "aes-256-gcm"
},
"types": {
"http": "HTTP",
"https": "HTTPS",
"socks4": "SOCKS4",
"socks5": "SOCKS5"
"socks5": "SOCKS5",
"ss": "Shadowsocks"
},
"tabs": {
"regular": "Обычный",
+5 -2
View File
@@ -278,13 +278,16 @@
"username": "用户名",
"usernamePlaceholder": "可选",
"password": "密码",
"passwordPlaceholder": "可选"
"passwordPlaceholder": "可选",
"cipher": "加密方式",
"cipherPlaceholder": "aes-256-gcm"
},
"types": {
"http": "HTTP",
"https": "HTTPS",
"socks4": "SOCKS4",
"socks5": "SOCKS5"
"socks5": "SOCKS5",
"ss": "Shadowsocks"
},
"tabs": {
"regular": "常规",
+7
View File
@@ -143,6 +143,13 @@
@layer base {
* {
@apply border-border outline-ring/50;
/* Thin, transparent-background scrollbars that look consistent across
Windows, macOS, and Linux. */
scrollbar-width: thin;
scrollbar-color: oklch(0.5 0 0 / 30%) transparent;
}
.dark * {
scrollbar-color: oklch(0.8 0 0 / 25%) transparent;
}
body {
@apply bg-background text-foreground;
+1 -1
View File
@@ -1,5 +1,5 @@
export interface ProxySettings {
proxy_type: string; // "http", "https", "socks4", or "socks5"
proxy_type: string; // "http", "https", "socks4", "socks5", or "ss" (Shadowsocks)
host: string;
port: number;
username?: string;