Compare commits

..

16 Commits

Author SHA1 Message Date
zhom d05ab23404 test: remove https tests 2026-03-16 18:21:01 +04:00
zhom 8511535d69 refactor: socks5 chaining 2026-03-16 17:48:02 +04:00
zhom 29dd5abb34 chore: exclude nightly tag 2026-03-16 15:55:29 +04:00
zhom b2d1456aa9 chore: version bump 2026-03-16 15:50:06 +04:00
zhom e3fc715cfa chore: cp instead of sync 2026-03-16 15:49:25 +04:00
zhom 2cf9013d28 chore: handle download interuptions 2026-03-16 15:48:52 +04:00
zhom 76dd0d84e8 refactor: check proxy validity via donut-proxy 2026-03-16 15:48:00 +04:00
zhom ccecd2a1e3 chore: version bump 2026-03-16 04:44:27 +04:00
zhom 238f7648cf chore: remove ref 2026-03-16 03:34:19 +04:00
zhom c4aee3a00b refactor: encrypt manifest for encrypted profiles 2026-03-16 03:33:44 +04:00
zhom 140e611085 test: e2e for encrypted sync 2026-03-16 02:57:31 +04:00
zhom b4488ee3ec refactor: make bypass of paid plan harder 2026-03-16 02:57:08 +04:00
zhom c4bfd4e253 chore: linting 2026-03-15 20:31:02 +04:00
zhom 0b3dac5da8 chore: icons 2026-03-15 20:06:40 +04:00
zhom db4c1fce6c Merge pull request #236 from zhom/dependabot/cargo/src-tauri/rust-dependencies-f0e0da4c3a
deps(rust)(deps): bump the rust-dependencies group across 1 directory with 13 updates
2026-03-15 12:01:28 -04:00
dependabot[bot] 081a1922df deps(rust)(deps): bump the rust-dependencies group across 1 directory with 13 updates
Bumps the rust-dependencies group with 9 updates in the /src-tauri directory:

| Package | From | To |
| --- | --- | --- |
| [zip](https://github.com/zip-rs/zip2) | `7.2.0` | `8.2.0` |
| [rand](https://github.com/rust-random/rand) | `0.9.2` | `0.10.0` |
| [rusqlite](https://github.com/rusqlite/rusqlite) | `0.38.0` | `0.39.0` |
| [smoltcp](https://github.com/smoltcp-rs/smoltcp) | `0.11.0` | `0.12.0` |
| [winreg](https://github.com/gentoo90/winreg-rs) | `0.55.0` | `0.56.0` |
| [resvg](https://github.com/linebender/resvg) | `0.46.0` | `0.47.0` |
| [portable-atomic-util](https://github.com/taiki-e/portable-atomic-util) | `0.2.5` | `0.2.6` |
| [tinyvec](https://github.com/Lokathor/tinyvec) | `1.10.0` | `1.11.0` |
| [uds_windows](https://github.com/haraldh/rust_uds_windows) | `1.2.0` | `1.2.1` |



Updates `zip` from 7.2.0 to 8.2.0
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v7.2.0...v8.2.0)

Updates `rand` from 0.9.2 to 0.10.0
- [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/rand_core-0.9.2...0.10.0)

Updates `rusqlite` from 0.38.0 to 0.39.0
- [Release notes](https://github.com/rusqlite/rusqlite/releases)
- [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md)
- [Commits](https://github.com/rusqlite/rusqlite/compare/v0.38.0...v0.39.0)

Updates `smoltcp` from 0.11.0 to 0.12.0
- [Release notes](https://github.com/smoltcp-rs/smoltcp/releases)
- [Changelog](https://github.com/smoltcp-rs/smoltcp/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smoltcp-rs/smoltcp/compare/v0.11.0...v0.12.0)

Updates `winreg` from 0.55.0 to 0.56.0
- [Release notes](https://github.com/gentoo90/winreg-rs/releases)
- [Changelog](https://github.com/gentoo90/winreg-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gentoo90/winreg-rs/compare/v0.55.0...v0.56.0)

Updates `resvg` from 0.46.0 to 0.47.0
- [Release notes](https://github.com/linebender/resvg/releases)
- [Changelog](https://github.com/linebender/resvg/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/resvg/compare/v0.46.0...v0.47.0)

Updates `libsqlite3-sys` from 0.36.0 to 0.37.0
- [Release notes](https://github.com/rusqlite/rusqlite/releases)
- [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md)
- [Commits](https://github.com/rusqlite/rusqlite/compare/v0.36.0...v0.37.0)

Updates `portable-atomic-util` from 0.2.5 to 0.2.6
- [Release notes](https://github.com/taiki-e/portable-atomic-util/releases)
- [Changelog](https://github.com/taiki-e/portable-atomic-util/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/portable-atomic-util/compare/v0.2.5...v0.2.6)

Updates `tiny-skia` from 0.11.4 to 0.12.0
- [Changelog](https://github.com/linebender/tiny-skia/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/tiny-skia/compare/v0.11.4...v0.12.0)

Updates `tiny-skia-path` from 0.11.4 to 0.12.0
- [Changelog](https://github.com/linebender/tiny-skia/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/tiny-skia/compare/v0.11.4...v0.12.0)

Updates `tinyvec` from 1.10.0 to 1.11.0
- [Changelog](https://github.com/Lokathor/tinyvec/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Lokathor/tinyvec/compare/v1.10.0...v1.11.0)

Updates `uds_windows` from 1.2.0 to 1.2.1
- [Release notes](https://github.com/haraldh/rust_uds_windows/releases)
- [Changelog](https://github.com/haraldh/rust_uds_windows/blob/master/CHANGELOG.md)
- [Commits](https://github.com/haraldh/rust_uds_windows/compare/v1.2.0...v1.2.1)

Updates `usvg` from 0.46.0 to 0.47.0
- [Release notes](https://github.com/linebender/resvg/releases)
- [Changelog](https://github.com/linebender/resvg/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/resvg/compare/v0.46.0...v0.47.0)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 8.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: rust-dependencies
- dependency-name: rand
  dependency-version: 0.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: rusqlite
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: smoltcp
  dependency-version: 0.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: winreg
  dependency-version: 0.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: resvg
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: libsqlite3-sys
  dependency-version: 0.37.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: portable-atomic-util
  dependency-version: 0.2.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tiny-skia
  dependency-version: 0.12.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tiny-skia-path
  dependency-version: 0.12.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tinyvec
  dependency-version: 1.11.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: uds_windows
  dependency-version: 1.2.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: usvg
  dependency-version: 0.47.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-15 14:38:49 +00:00
38 changed files with 991 additions and 193 deletions
-1
View File
@@ -74,7 +74,6 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Check if first-time contributor
id: check-first-time
+14 -14
View File
@@ -273,10 +273,10 @@ jobs:
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
mkdir -p /tmp/repo
aws s3 sync "s3://${R2_BUCKET}/dists" /tmp/repo/dists \
--endpoint-url "${R2_ENDPOINT}" --delete 2>/dev/null || true
aws s3 sync "s3://${R2_BUCKET}/repodata" /tmp/repo/repodata \
--endpoint-url "${R2_ENDPOINT}" --delete 2>/dev/null || true
aws s3 cp "s3://${R2_BUCKET}/dists" /tmp/repo/dists \
--endpoint-url "${R2_ENDPOINT}" --recursive 2>/dev/null || true
aws s3 cp "s3://${R2_BUCKET}/repodata" /tmp/repo/repodata \
--endpoint-url "${R2_ENDPOINT}" --recursive 2>/dev/null || true
- name: Generate repository with repogen
run: |
@@ -299,14 +299,14 @@ jobs:
R2_ENDPOINT: "https://${{ secrets.R2_ENDPOINT_URL }}"
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
aws s3 sync /tmp/repo/dists "s3://${R2_BUCKET}/dists" \
--endpoint-url "${R2_ENDPOINT}" --delete
aws s3 sync /tmp/repo/pool "s3://${R2_BUCKET}/pool" \
--endpoint-url "${R2_ENDPOINT}"
aws s3 sync /tmp/repo/repodata "s3://${R2_BUCKET}/repodata" \
--endpoint-url "${R2_ENDPOINT}" --delete
aws s3 sync /tmp/repo/Packages "s3://${R2_BUCKET}/Packages" \
--endpoint-url "${R2_ENDPOINT}"
aws s3 cp /tmp/repo/dists "s3://${R2_BUCKET}/dists" \
--endpoint-url "${R2_ENDPOINT}" --recursive
aws s3 cp /tmp/repo/pool "s3://${R2_BUCKET}/pool" \
--endpoint-url "${R2_ENDPOINT}" --recursive
aws s3 cp /tmp/repo/repodata "s3://${R2_BUCKET}/repodata" \
--endpoint-url "${R2_ENDPOINT}" --recursive
aws s3 cp /tmp/repo/Packages "s3://${R2_BUCKET}/Packages" \
--endpoint-url "${R2_ENDPOINT}" --recursive
- name: Verify upload
env:
@@ -317,6 +317,6 @@ jobs:
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
echo "DEB repo:"
aws s3 ls "s3://${R2_BUCKET}/dists/stable/" --endpoint-url "${R2_ENDPOINT}"
aws s3 ls "s3://${R2_BUCKET}/dists/stable/" --endpoint-url "${R2_ENDPOINT}" || echo " (listing not available)"
echo "RPM repo:"
aws s3 ls "s3://${R2_BUCKET}/repodata/" --endpoint-url "${R2_ENDPOINT}"
aws s3 ls "s3://${R2_BUCKET}/repodata/" --endpoint-url "${R2_ENDPOINT}" || echo " (listing not available)"
+10
View File
@@ -0,0 +1,10 @@
# Prevent pushing the 'nightly' tag — it is managed by CI
if git rev-parse nightly >/dev/null 2>&1; then
LOCAL_NIGHTLY=$(git rev-parse nightly)
REMOTE_NIGHTLY=$(git ls-remote --tags origin refs/tags/nightly 2>/dev/null | awk '{print $1}')
if [ -n "$REMOTE_NIGHTLY" ] && [ "$LOCAL_NIGHTLY" != "$REMOTE_NIGHTLY" ]; then
echo "⚠ Skipping push of 'nightly' tag (managed by CI)"
# Delete the local nightly tag so --tags won't try to push it
git tag -d nightly >/dev/null 2>&1 || true
fi
fi
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.16.1",
"version": "0.17.1",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
+100 -66
View File
@@ -26,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"cpufeatures 0.2.17",
]
[[package]]
@@ -182,7 +182,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -193,7 +193,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -230,7 +230,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"cpufeatures 0.2.17",
"password-hash",
]
@@ -640,7 +640,7 @@ dependencies = [
"cc",
"cfg-if",
"constant_time_eq 0.4.2",
"cpufeatures",
"cpufeatures 0.2.17",
]
[[package]]
@@ -843,15 +843,6 @@ dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
dependencies = [
"libbz2-rs-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
@@ -1020,7 +1011,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"cpufeatures 0.2.17",
]
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.0",
]
[[package]]
@@ -1030,7 +1032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"chacha20 0.9.1",
"cipher",
"poly1305",
"zeroize",
@@ -1267,6 +1269,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
@@ -1408,7 +1419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"fiat-crypto",
"rustc_version",
@@ -1631,7 +1642,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1706,7 +1717,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.16.1"
version = "0.17.1"
dependencies = [
"aes",
"aes-gcm",
@@ -1717,7 +1728,7 @@ dependencies = [
"base64 0.22.1",
"blake3",
"boringtun",
"bzip2 0.6.1",
"bzip2",
"cbc",
"chrono",
"chrono-tz",
@@ -1749,7 +1760,7 @@ dependencies = [
"pbkdf2",
"playwright",
"quick-xml 0.39.2",
"rand 0.9.2",
"rand 0.10.0",
"regex-lite",
"reqwest 0.13.2",
"resvg",
@@ -1788,9 +1799,9 @@ dependencies = [
"utoipa-axum",
"uuid",
"windows 0.62.2",
"winreg",
"winreg 0.56.0",
"wiremock",
"zip 7.2.0",
"zip 8.2.0",
]
[[package]]
@@ -1846,7 +1857,7 @@ dependencies = [
"rustc_version",
"toml 0.9.12+spec-1.1.0",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@@ -1968,7 +1979,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -2493,6 +2504,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.0",
"wasip2",
"wasip3",
]
@@ -3474,12 +3486,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "libbz2-rs-sys"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
[[package]]
name = "libc"
version = "0.2.183"
@@ -3526,9 +3532,9 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
version = "0.36.0"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
dependencies = [
"cc",
"pkg-config",
@@ -4340,7 +4346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.45.0",
]
[[package]]
@@ -4770,7 +4776,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
@@ -4782,7 +4788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
@@ -4795,9 +4801,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.5"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5"
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
dependencies = [
"portable-atomic",
]
@@ -5073,6 +5079,17 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
dependencies = [
"chacha20 0.10.0",
"getrandom 0.4.2",
"rand_core 0.10.0",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
@@ -5130,6 +5147,12 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
[[package]]
name = "rand_hc"
version = "0.2.0"
@@ -5403,9 +5426,9 @@ dependencies = [
[[package]]
name = "resvg"
version = "0.46.0"
version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419"
checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81"
dependencies = [
"gif",
"image-webp",
@@ -5521,9 +5544,9 @@ dependencies = [
[[package]]
name = "rusqlite"
version = "0.38.0"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3"
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
dependencies = [
"bitflags 2.11.0",
"fallible-iterator",
@@ -5585,7 +5608,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6053,7 +6076,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -6064,7 +6087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -6181,9 +6204,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smoltcp"
version = "0.11.0"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a1a996951e50b5971a2c8c0fa05a381480d70a933064245c4a223ddc87ccc97"
checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb"
dependencies = [
"bitflags 1.3.2",
"byteorder",
@@ -6200,7 +6223,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6934,10 +6957,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -7049,24 +7072,24 @@ dependencies = [
[[package]]
name = "tiny-skia"
version = "0.11.4"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png 0.17.16",
"png 0.18.1",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.11.4"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
dependencies = [
"arrayref",
"bytemuck",
@@ -7085,9 +7108,9 @@ dependencies = [
[[package]]
name = "tinyvec"
version = "1.10.0"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
@@ -7453,13 +7476,13 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "uds_windows"
version = "1.2.0"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7618,9 +7641,9 @@ dependencies = [
[[package]]
name = "usvg"
version = "0.46.0"
version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f"
checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de"
dependencies = [
"base64 0.22.1",
"data-url",
@@ -7637,6 +7660,7 @@ dependencies = [
"strict-num",
"svgtypes",
"tiny-skia-path",
"ttf-parser",
"unicode-bidi",
"unicode-script",
"unicode-vo",
@@ -8056,7 +8080,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -8569,6 +8593,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "winreg"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.59.0",
]
[[package]]
name = "wiremock"
version = "0.6.5"
@@ -8989,7 +9023,7 @@ checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"aes",
"arbitrary",
"bzip2 0.5.2",
"bzip2",
"constant_time_eq 0.3.1",
"crc32fast",
"crossbeam-utils",
@@ -9013,9 +9047,9 @@ dependencies = [
[[package]]
name = "zip"
version = "7.2.0"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0"
checksum = "b680f2a0cd479b4cff6e1233c483fdead418106eae419dc60200ae9850f6d004"
dependencies = [
"crc32fast",
"flate2",
+7 -7
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.16.1"
version = "0.17.1"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -30,7 +30,7 @@ path = "src/bin/donut_daemon.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
resvg = "0.46"
resvg = "0.47"
[dependencies]
serde_json = "1"
@@ -57,7 +57,7 @@ base64 = "0.22"
libc = "0.2"
async-trait = "0.1"
futures-util = "0.3"
zip = { version = "7", default-features = false, features = ["deflate-flate2"] }
zip = { version = "8", default-features = false, features = ["deflate-flate2"] }
tar = "0"
bzip2 = "0"
flate2 = "1"
@@ -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.9.2"
rand = "0.10.0"
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.2"
argon2 = "0.5"
@@ -96,7 +96,7 @@ playwright = { git = "https://github.com/sctg-development/playwright-rust", bran
# Wayfern CDP integration
tokio-tungstenite = { version = "0.28", features = ["native-tls"] }
rusqlite = { version = "0.38", features = ["bundled"] }
rusqlite = { version = "0.39", features = ["bundled"] }
serde_yaml = "0.9"
thiserror = "2.0"
regex-lite = "0.1"
@@ -106,7 +106,7 @@ quick-xml = { version = "0.39", features = ["serialize"] }
# VPN support
boringtun = "0.7"
smoltcp = { version = "0.11", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
smoltcp = { version = "0.12", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
# Daemon dependencies (tray icon)
tray-icon = "0.21"
@@ -126,7 +126,7 @@ objc2 = "0.6.3"
objc2-app-kit = { version = "0.3.2", features = ["NSWindow", "NSApplication", "NSRunningApplication"] }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.55"
winreg = "0.56"
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_System_ProcessStatus",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 745 B

After

Width:  |  Height:  |  Size: 487 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

+18 -9
View File
@@ -2157,11 +2157,14 @@ impl BrowserRunner {
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| format!("Profile '{profile_id}' not found"))?;
if profile.is_cross_os() {
if profile.is_cross_os()
&& !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(profile.host_os.as_deref())
.await
{
return Err(format!(
"Cannot open URL with profile '{}': it was created on {} and is not supported on this system",
"Cannot open URL with profile '{}': cross-OS fingerprints require a paid subscription",
profile.name,
profile.host_os.as_deref().unwrap_or("unknown")
));
}
@@ -2193,11 +2196,14 @@ pub async fn launch_browser_profile(
profile.id
);
if profile.is_cross_os() {
if profile.is_cross_os()
&& !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(profile.host_os.as_deref())
.await
{
return Err(format!(
"Cannot launch profile '{}': it was created on {} and is not supported on this system",
"Cannot launch profile '{}': cross-OS fingerprints require a paid subscription",
profile.name,
profile.host_os.as_deref().unwrap_or("unknown")
));
}
@@ -2510,11 +2516,14 @@ pub async fn launch_browser_profile_with_debugging(
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, String> {
if profile.is_cross_os() {
if profile.is_cross_os()
&& !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(profile.host_os.as_deref())
.await
{
return Err(format!(
"Cannot launch profile '{}': it was created on {} and is not supported on this system",
"Cannot launch profile '{}': cross-OS fingerprints require a paid subscription",
profile.name,
profile.host_os.as_deref().unwrap_or("unknown")
));
}
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! Converts fingerprints to Camoufox configuration format and builds launch options.
use rand::Rng;
use rand::RngExt;
use serde_yaml;
use std::collections::HashMap;
use std::path::Path;
@@ -2,7 +2,7 @@
//!
//! Implements weighted random sampling from conditional probability distributions.
use rand::Rng;
use rand::RngExt;
use serde::Deserialize;
use std::collections::HashMap;
+1 -1
View File
@@ -9,7 +9,7 @@ use directories::BaseDirs;
use maxminddb::{geoip2, Reader};
use quick_xml::events::Event;
use quick_xml::Reader as XmlReader;
use rand::Rng;
use rand::RngExt;
use std::collections::HashMap;
use std::net::IpAddr;
use std::path::PathBuf;
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! Samples realistic WebGL configurations based on OS-specific probability distributions.
use rand::Rng;
use rand::RngExt;
use rusqlite::{Connection, Result as SqliteResult};
use std::collections::HashMap;
use std::io::Write;
+51 -4
View File
@@ -308,12 +308,40 @@ impl Downloader {
.resolve_download_url(browser_type.clone(), version, download_info)
.await?;
// Determine if we have a partial file to resume
// Check existing file size — if it matches the expected size, skip download
let mut existing_size: u64 = 0;
if let Ok(meta) = std::fs::metadata(&file_path) {
existing_size = meta.len();
}
// Do a HEAD request to get the expected file size for skip/resume decisions
let head_response = self
.client
.head(&download_url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
)
.send()
.await
.ok();
let expected_size = head_response.as_ref().and_then(|r| r.content_length());
// If existing file matches expected size, skip download entirely
if existing_size > 0 {
if let Some(expected) = expected_size {
if existing_size == expected {
log::info!(
"Archive {} already exists with correct size ({} bytes), skipping download",
file_path.display(),
existing_size
);
return Ok(file_path);
}
}
}
// Build request, add Range only if we have bytes. If the server responds with 416 (Range Not
// Satisfiable), delete the partial file and retry once without the Range header.
let response = {
@@ -683,11 +711,16 @@ impl Downloader {
// Do not remove the archive here. We keep it until verification succeeds.
}
Err(e) => {
// Do not remove the archive or extracted files. Just drop the registry entry
// so it won't be reported as downloaded.
log::error!("Extraction failed for {browser_str} {version}: {e}");
// Delete the corrupt/invalid archive so a fresh download happens next time
if download_path.exists() {
log::info!("Deleting corrupt archive: {}", download_path.display());
let _ = std::fs::remove_file(&download_path);
}
let _ = self.registry.remove_browser(&browser_str, &version);
let _ = self.registry.save();
// Remove browser-version pair from downloading set on error
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
@@ -696,6 +729,20 @@ impl Downloader {
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
}
// Emit error stage so the UI shows a toast
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "error".to_string(),
};
let _ = events::emit("download-progress", &progress);
return Err(format!("Failed to extract browser: {e}").into());
}
}
+23 -22
View File
@@ -7,7 +7,7 @@ use crate::downloader::DownloadProgress;
use crate::events;
#[cfg(target_os = "macos")]
use std::process::Command;
use tokio::process::Command;
#[cfg(target_os = "macos")]
use std::fs::create_dir_all;
@@ -232,17 +232,8 @@ impl Extractor {
&self,
file_path: &Path,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// First check file extension for DMG files since they're common on macOS
// and can have misleading magic numbers
if let Some(ext) = file_path.extension().and_then(|ext| ext.to_str()) {
if ext.to_lowercase() == "dmg" {
return Ok("dmg".to_string());
}
if ext.to_lowercase() == "msi" {
return Ok("msi".to_string());
}
}
// Always check magic bytes first — the file extension may be wrong
// (e.g. CDN serving a ZIP with .dmg extension)
let mut file = File::open(file_path)?;
let mut buffer = [0u8; 12]; // Read first 12 bytes for magic number detection
file.read_exact(&mut buffer)?;
@@ -357,16 +348,20 @@ impl Extractor {
.args([
"attach",
"-nobrowse",
"-noverify",
"-noautoopen",
"-mountpoint",
mount_point.to_str().unwrap(),
dmg_path.to_str().unwrap(),
])
.output()?;
.stdin(std::process::Stdio::null())
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
log::info!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}");
log::error!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}");
// Clean up mount point before returning error
let _ = fs::remove_dir_all(&mount_point);
@@ -382,12 +377,13 @@ impl Extractor {
let app_entry = match app_result {
Ok(app_path) => app_path,
Err(e) => {
log::info!("Failed to find .app in mount point: {e}");
log::error!("Failed to find .app in mount point: {e}");
// Try to unmount before returning error
let _ = Command::new("hdiutil")
.args(["detach", "-force", mount_point.to_str().unwrap()])
.output();
.output()
.await;
let _ = fs::remove_dir_all(&mount_point);
return Err("No .app found after extraction".into());
@@ -407,16 +403,18 @@ impl Extractor {
app_entry.to_str().unwrap(),
app_path.to_str().unwrap(),
])
.output()?;
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::info!("Failed to copy app: {stderr}");
log::error!("Failed to copy app: {stderr}");
// Unmount before returning error
let _ = Command::new("hdiutil")
.args(["detach", "-force", mount_point.to_str().unwrap()])
.output();
.output()
.await;
let _ = fs::remove_dir_all(&mount_point);
return Err(format!("Failed to copy app: {stderr}").into());
@@ -427,18 +425,21 @@ impl Extractor {
// Remove quarantine attributes
let _ = Command::new("xattr")
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
.output();
.output()
.await;
let _ = Command::new("xattr")
.args(["-cr", app_path.to_str().unwrap()])
.output();
.output()
.await;
log::info!("Removed quarantine attributes");
// Unmount the DMG
let output = Command::new("hdiutil")
.args(["detach", mount_point.to_str().unwrap()])
.output()?;
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
+12 -1
View File
@@ -174,6 +174,13 @@ impl GeoIPDownloader {
let mmdb_path = Self::get_mmdb_file_path()?;
// Always download to a temp file first, then atomically rename.
// This prevents corruption if the app is closed mid-download.
let temp_path = mmdb_path.with_extension("mmdb.downloading");
// Remove any leftover temp file from a previous interrupted download
let _ = fs::remove_file(&temp_path).await;
// Download the file
let response = self.client.get(&download_url).send().await?;
@@ -189,7 +196,7 @@ impl GeoIPDownloader {
let total_size = response.content_length().unwrap_or(0);
let mut downloaded: u64 = 0;
let mut file = fs::File::create(&mmdb_path).await?;
let mut file = fs::File::create(&temp_path).await?;
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
@@ -237,6 +244,10 @@ impl GeoIPDownloader {
}
file.flush().await?;
drop(file);
// Atomically replace the old database with the new one
fs::rename(&temp_path, &mmdb_path).await?;
// Write download timestamp
let timestamp_path = Self::get_timestamp_path();
+2 -2
View File
@@ -1,4 +1,4 @@
use rand::Rng;
use rand::{Rng, RngExt};
use std::collections::{HashMap, HashSet};
const PROB_ERROR: f64 = 0.04;
@@ -117,7 +117,7 @@ fn normal_sample(rng: &mut impl Rng, mean: f64, std_dev: f64) -> f64 {
// Box-Muller transform
let u1: f64 = rng.random::<f64>().max(1e-10);
let u2: f64 = rng.random::<f64>();
let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
let z = (-2.0_f64 * u1.ln()).sqrt() * (2.0_f64 * std::f64::consts::PI * u2).cos();
mean + std_dev * z
}
+9 -1
View File
@@ -293,9 +293,17 @@ async fn fetch_dynamic_proxy(
url: String,
format: String,
) -> Result<crate::browser::ProxySettings, String> {
crate::proxy_manager::PROXY_MANAGER
let settings = crate::proxy_manager::PROXY_MANAGER
.fetch_dynamic_proxy(&url, &format)
.await?;
// Validate the proxy actually works by routing through a temporary local proxy
crate::proxy_manager::PROXY_MANAGER
.check_proxy_validity("_dynamic_test", &settings)
.await
.map_err(|e| format!("Proxy resolved but connection failed: {e}"))?;
Ok(settings)
}
#[tauri::command]
+230 -6
View File
@@ -928,19 +928,33 @@ impl ProxyManager {
url
}
// Check if a proxy is valid by making HTTP requests through it
// Check if a proxy is valid by routing through a temporary local donut-proxy.
// This tests the exact same code path the browser uses, ensuring that if the
// check passes, the browser connection will work too.
pub async fn check_proxy_validity(
&self,
proxy_id: &str,
proxy_settings: &ProxySettings,
) -> Result<ProxyCheckResult, String> {
let proxy_url = Self::build_proxy_url(proxy_settings);
let upstream_url = Self::build_proxy_url(proxy_settings);
// Fetch public IP through the proxy using shared IP utilities
let ip = match ip_utils::fetch_public_ip(Some(&proxy_url)).await {
// Start a temporary local proxy that tunnels through the upstream
let proxy_config = crate::proxy_runner::start_proxy_process(Some(upstream_url), None)
.await
.map_err(|e| format!("Failed to start test proxy: {e}"))?;
let local_url = format!("http://127.0.0.1:{}", proxy_config.local_port.unwrap_or(0));
let proxy_id_clone = proxy_config.id.clone();
// Fetch public IP through the local proxy (same path the browser uses)
let ip_result = ip_utils::fetch_public_ip(Some(&local_url)).await;
// Stop the temporary proxy regardless of result
let _ = crate::proxy_runner::stop_proxy_process(&proxy_id_clone).await;
let ip = match ip_result {
Ok(ip) => ip,
Err(e) => {
// Save failed check result
let failed_result = ProxyCheckResult {
ip: String::new(),
city: None,
@@ -1054,9 +1068,10 @@ impl ProxyManager {
let proxy_type = obj
.get("type")
.or_else(|| obj.get("proxy_type"))
.or_else(|| obj.get("protocol"))
.and_then(|v| v.as_str())
.unwrap_or("http")
.to_string();
.to_lowercase();
let username = obj
.get("username")
@@ -3391,4 +3406,213 @@ mod tests {
delete_proxy_config(&id);
}
#[test]
fn test_parse_dynamic_proxy_json_standard_format() {
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "user1", "password": "pass1"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "1.2.3.4");
assert_eq!(result.port, 8080);
assert_eq!(result.proxy_type, "http");
assert_eq!(result.username.as_deref(), Some("user1"));
assert_eq!(result.password.as_deref(), Some("pass1"));
}
#[test]
fn test_parse_dynamic_proxy_json_host_alias() {
let body = r#"{"host": "proxy.example.com", "port": 3128}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert!(result.username.is_none());
assert!(result.password.is_none());
}
#[test]
fn test_parse_dynamic_proxy_json_user_pass_aliases() {
let body = r#"{"ip": "10.0.0.1", "port": 1080, "user": "u", "pass": "p"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.username.as_deref(), Some("u"));
assert_eq!(result.password.as_deref(), Some("p"));
}
#[test]
fn test_parse_dynamic_proxy_json_port_as_string() {
let body = r#"{"ip": "1.2.3.4", "port": "9090"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.port, 9090);
}
#[test]
fn test_parse_dynamic_proxy_json_with_proxy_type() {
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "socks5"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.proxy_type, "socks5");
let body2 = r#"{"ip": "1.2.3.4", "port": 1080, "proxy_type": "socks4"}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.proxy_type, "socks4");
// "protocol" field alias
let body3 = r#"{"ip": "1.2.3.4", "port": 1080, "protocol": "socks5"}"#;
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
assert_eq!(result3.proxy_type, "socks5");
}
#[test]
fn test_parse_dynamic_proxy_json_normalizes_case() {
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "SOCKS5"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.proxy_type, "socks5");
let body2 = r#"{"ip": "1.2.3.4", "port": 8080, "protocol": "HTTP"}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.proxy_type, "http");
}
#[test]
fn test_parse_dynamic_proxy_json_empty_credentials_treated_as_none() {
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "", "password": ""}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert!(result.username.is_none());
assert!(result.password.is_none());
}
#[test]
fn test_parse_dynamic_proxy_json_missing_ip() {
let body = r#"{"port": 8080}"#;
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
assert!(err.contains("ip") || err.contains("host"));
}
#[test]
fn test_parse_dynamic_proxy_json_missing_port() {
let body = r#"{"ip": "1.2.3.4"}"#;
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
assert!(err.contains("port"));
}
#[test]
fn test_parse_dynamic_proxy_json_invalid_json() {
let err = ProxyManager::parse_dynamic_proxy_json("not json").unwrap_err();
assert!(err.contains("Invalid JSON"));
}
#[test]
fn test_parse_dynamic_proxy_json_not_object() {
let err = ProxyManager::parse_dynamic_proxy_json("[1,2,3]").unwrap_err();
assert!(err.contains("not an object"));
}
#[test]
fn test_parse_dynamic_proxy_text_host_port_user_pass() {
let body = "proxy.example.com:8080:user1:pass1";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 8080);
assert_eq!(result.username.as_deref(), Some("user1"));
assert_eq!(result.password.as_deref(), Some("pass1"));
}
#[test]
fn test_parse_dynamic_proxy_text_protocol_url_format() {
let body = "http://user:pass@proxy.example.com:3128";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert_eq!(result.proxy_type, "http");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[test]
fn test_parse_dynamic_proxy_text_with_whitespace() {
let body = " \n proxy.example.com:8080:user:pass \n ";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 8080);
}
#[test]
fn test_parse_dynamic_proxy_text_empty() {
let err = ProxyManager::parse_dynamic_proxy_text("").unwrap_err();
assert!(err.contains("Empty"));
}
#[test]
fn test_parse_dynamic_proxy_text_whitespace_only() {
let err = ProxyManager::parse_dynamic_proxy_text(" \n \n ").unwrap_err();
assert!(err.contains("Empty"));
}
#[test]
fn test_stored_proxy_is_dynamic() {
let mut proxy = StoredProxy::new(
"test".to_string(),
ProxySettings {
proxy_type: "http".to_string(),
host: "h.com".to_string(),
port: 80,
username: None,
password: None,
},
);
assert!(!proxy.is_dynamic());
proxy.dynamic_proxy_url = Some("https://api.example.com/proxy".to_string());
assert!(proxy.is_dynamic());
}
#[test]
fn test_is_dynamic_proxy_via_manager() {
let pm = ProxyManager::new();
let mut proxy = StoredProxy::new(
"DynTest".to_string(),
ProxySettings {
proxy_type: "http".to_string(),
host: "dynamic".to_string(),
port: 0,
username: None,
password: None,
},
);
proxy.dynamic_proxy_url = Some("https://api.example.com/proxy".to_string());
proxy.dynamic_proxy_format = Some("json".to_string());
let id = proxy.id.clone();
pm.stored_proxies.lock().unwrap().insert(id.clone(), proxy);
assert!(pm.is_dynamic_proxy(&id));
assert!(!pm.is_dynamic_proxy("nonexistent"));
}
#[tokio::test]
async fn test_resolve_dynamic_proxy_not_dynamic() {
let pm = ProxyManager::new();
let proxy = StoredProxy::new(
"Regular".to_string(),
ProxySettings {
proxy_type: "http".to_string(),
host: "1.2.3.4".to_string(),
port: 8080,
username: None,
password: None,
},
);
let id = proxy.id.clone();
pm.stored_proxies.lock().unwrap().insert(id.clone(), proxy);
let err = pm.resolve_dynamic_proxy(&id).await.unwrap_err();
assert!(err.contains("not a dynamic proxy"));
}
#[tokio::test]
async fn test_resolve_dynamic_proxy_not_found() {
let pm = ProxyManager::new();
let err = pm.resolve_dynamic_proxy("nonexistent").await.unwrap_err();
assert!(err.contains("not found"));
}
}
+8 -6
View File
@@ -1062,14 +1062,16 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
let matcher = bypass_matcher.clone();
tokio::task::spawn(async move {
// Read first bytes to detect CONNECT requests
// CONNECT requests need special handling for tunneling
// Use a larger buffer to ensure we can detect CONNECT even with partial reads
// Wait for the stream to have readable data before attempting to read.
// This prevents read() from returning 0 on a fresh connection before
// the client's data arrives.
if stream.readable().await.is_err() {
return;
}
let mut peek_buffer = [0u8; 16];
match stream.read(&mut peek_buffer).await {
Ok(0) => {
log::error!("DEBUG: Connection closed immediately (0 bytes read)");
}
Ok(0) => {}
Ok(n) => {
// Check if this looks like a CONNECT request
// Be more lenient - check if the first bytes match "CONNECT" (case-insensitive)
+2 -2
View File
@@ -200,7 +200,7 @@ impl SettingsManager {
) -> Result<String, Box<dyn std::error::Error>> {
// Generate a secure random token (base64 encoded for URL safety)
let token_bytes: [u8; 32] = {
use rand::RngCore;
use rand::Rng;
let mut rng = rand::rng();
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
@@ -390,7 +390,7 @@ impl SettingsManager {
app_handle: &tauri::AppHandle,
) -> Result<String, Box<dyn std::error::Error>> {
let token_bytes: [u8; 32] = {
use rand::RngCore;
use rand::Rng;
let mut rng = rand::rng();
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
+60 -18
View File
@@ -460,7 +460,9 @@ impl SyncEngine {
// Try to download remote manifest
let remote_manifest_key = format!("{}profiles/{}/manifest.json", key_prefix, profile_id);
let remote_manifest = self.download_manifest(&remote_manifest_key).await?;
let remote_manifest = self
.download_manifest(&remote_manifest_key, encryption_key.as_ref())
.await?;
// Compute diff
let diff = compute_diff(&local_manifest, remote_manifest.as_ref());
@@ -573,7 +575,12 @@ impl SyncEngine {
// Upload manifest.json last for atomicity
self
.upload_manifest(&profile_id, &final_manifest, &key_prefix)
.upload_manifest(
&profile_id,
&final_manifest,
encryption_key.as_ref(),
&key_prefix,
)
.await?;
// Sync completed successfully — clean up resume state
@@ -614,7 +621,11 @@ impl SyncEngine {
Ok(())
}
async fn download_manifest(&self, key: &str) -> SyncResult<Option<SyncManifest>> {
async fn download_manifest(
&self,
key: &str,
encryption_key: Option<&[u8; 32]>,
) -> SyncResult<Option<SyncManifest>> {
let stat = self.client.stat(key).await?;
if !stat.exists {
return Ok(None);
@@ -623,30 +634,58 @@ impl SyncEngine {
let presign = self.client.presign_download(key).await?;
let data = self.client.download_bytes(&presign.url).await?;
let manifest: SyncManifest = serde_json::from_slice(&data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse manifest: {e}")))?;
// Try parsing as plaintext JSON first (unencrypted or backwards-compatible)
if let Ok(manifest) = serde_json::from_slice::<SyncManifest>(&data) {
return Ok(Some(manifest));
}
Ok(Some(manifest))
// If plaintext parse failed and we have an encryption key, try decrypting
if let Some(key) = encryption_key {
let decrypted = encryption::decrypt_bytes(key, &data)
.map_err(|e| SyncError::InvalidData(format!("Failed to decrypt manifest: {e}")))?;
let manifest: SyncManifest = serde_json::from_slice(&decrypted).map_err(|e| {
SyncError::SerializationError(format!("Failed to parse decrypted manifest: {e}"))
})?;
return Ok(Some(manifest));
}
Err(SyncError::SerializationError(
"Failed to parse manifest (not valid JSON and no encryption key available)".to_string(),
))
}
async fn upload_manifest(
&self,
profile_id: &str,
manifest: &SyncManifest,
encryption_key: Option<&[u8; 32]>,
key_prefix: &str,
) -> SyncResult<()> {
let json = serde_json::to_string_pretty(manifest)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize manifest: {e}")))?;
let upload_data = if let Some(key) = encryption_key {
encryption::encrypt_bytes(key, json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to encrypt manifest: {e}")))?
} else {
json.into_bytes()
};
let content_type = if encryption_key.is_some() {
"application/octet-stream"
} else {
"application/json"
};
let remote_key = format!("{}profiles/{}/manifest.json", key_prefix, profile_id);
let presign = self
.client
.presign_upload(&remote_key, Some("application/json"))
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
.upload_bytes(&presign.url, &upload_data, Some(content_type))
.await?;
Ok(())
@@ -2140,16 +2179,9 @@ impl SyncEngine {
return Ok(true);
}
// Download manifest
let manifest = self.download_manifest(&manifest_key).await?;
let Some(manifest) = manifest else {
return Err(SyncError::InvalidData(
"Remote manifest not found".to_string(),
));
};
// If remote manifest is encrypted, we need the E2E password
let encryption_key = if manifest.encrypted {
// Derive encryption key before downloading manifest if profile uses encrypted sync.
// The manifest itself may be encrypted (new behavior) or plaintext (backwards compat).
let encryption_key = if profile.is_encrypted_sync() {
let password = encryption::load_e2e_password()
.map_err(|e| SyncError::InvalidData(format!("Failed to load E2E password: {e}")))?
.ok_or_else(|| {
@@ -2168,6 +2200,16 @@ impl SyncEngine {
None
};
// Download manifest (may be encrypted for e2e profiles)
let manifest = self
.download_manifest(&manifest_key, encryption_key.as_ref())
.await?;
let Some(manifest) = manifest else {
return Err(SyncError::InvalidData(
"Remote manifest not found".to_string(),
));
};
// Ensure profile directory exists
fs::create_dir_all(&profile_dir).map_err(|e| {
SyncError::IoError(format!(
+6 -6
View File
@@ -73,11 +73,11 @@ struct WgRxToken {
}
impl RxToken for WgRxToken {
fn consume<R, F>(mut self, f: F) -> R
fn consume<R, F>(self, f: F) -> R
where
F: FnOnce(&mut [u8]) -> R,
F: FnOnce(&[u8]) -> R,
{
f(&mut self.data)
f(&self.data)
}
}
@@ -173,7 +173,7 @@ fn parse_cidr_address(addr: &str) -> Result<(IpCidr, IpAddress), VpnError> {
))
}
std::net::IpAddr::V6(v6) => {
let smol_ip = smoltcp::wire::Ipv6Address::from_bytes(&v6.octets());
let smol_ip = smoltcp::wire::Ipv6Address::from(v6.octets());
Ok((
IpCidr::new(IpAddress::Ipv6(smol_ip), prefix),
IpAddress::Ipv6(smol_ip),
@@ -331,7 +331,7 @@ impl WireGuardSocks5Server {
// Set default gateway
match local_ip {
IpAddress::Ipv4(v4) => {
let octets = v4.as_bytes();
let octets = v4.octets();
let gw = Ipv4Address::new(octets[0], octets[1], octets[2], 1);
iface
.routes_mut()
@@ -523,7 +523,7 @@ impl WireGuardSocks5Server {
IpAddress::Ipv4(Ipv4Address::new(o[0], o[1], o[2], o[3]))
}
std::net::IpAddr::V6(v6) => {
IpAddress::Ipv6(smoltcp::wire::Ipv6Address::from_bytes(&v6.octets()))
IpAddress::Ipv6(smoltcp::wire::Ipv6Address::from(v6.octets()))
}
};
+1 -1
View File
@@ -6,7 +6,7 @@ use aes_gcm::{
Aes256Gcm, Nonce,
};
use chrono::Utc;
use rand::Rng;
use rand::RngExt;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.16.1",
"version": "0.17.1",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+177
View File
@@ -1121,3 +1121,180 @@ async fn test_no_bypass_rules_all_through_upstream(
Ok(())
}
/// Start a minimal SOCKS5 proxy that tunnels connections to the real destination.
/// Returns (port, JoinHandle).
async fn start_mock_socks5_server() -> (u16, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(async move {
while let Ok((mut client, _)) = listener.accept().await {
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// SOCKS5 handshake: client sends version + methods
let mut buf = [0u8; 256];
let n = client.read(&mut buf).await.unwrap_or(0);
if n < 2 || buf[0] != 0x05 {
return;
}
// Reply: version 5, no auth required
client.write_all(&[0x05, 0x00]).await.ok();
// Read connect request: VER CMD RSV ATYP DST.ADDR DST.PORT
let n = client.read(&mut buf).await.unwrap_or(0);
if n < 7 || buf[1] != 0x01 {
client
.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await
.ok();
return;
}
let (target_host, target_port) = match buf[3] {
0x01 => {
// IPv4
if n < 10 {
return;
}
let ip = format!("{}.{}.{}.{}", buf[4], buf[5], buf[6], buf[7]);
let port = u16::from_be_bytes([buf[8], buf[9]]);
(ip, port)
}
0x03 => {
// Domain
let domain_len = buf[4] as usize;
if n < 5 + domain_len + 2 {
return;
}
let domain = String::from_utf8_lossy(&buf[5..5 + domain_len]).to_string();
let port = u16::from_be_bytes([buf[5 + domain_len], buf[6 + domain_len]]);
(domain, port)
}
_ => return,
};
// Connect to target
let target =
match tokio::net::TcpStream::connect(format!("{}:{}", target_host, target_port)).await {
Ok(t) => t,
Err(_) => {
client
.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await
.ok();
return;
}
};
// Success reply
client
.write_all(&[0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 0])
.await
.ok();
// Bidirectional relay
let (mut cr, mut cw) = tokio::io::split(client);
let (mut tr, mut tw) = tokio::io::split(target);
tokio::select! {
_ = tokio::io::copy(&mut cr, &mut tw) => {}
_ = tokio::io::copy(&mut tr, &mut cw) => {}
}
});
}
});
sleep(Duration::from_millis(100)).await;
(port, handle)
}
/// Test that a SOCKS5 upstream proxy works end-to-end through donut-proxy.
/// Starts a mock SOCKS5 server, a mock HTTP target server,
/// then routes requests through donut-proxy -> SOCKS5 -> target.
#[tokio::test]
#[serial]
async fn test_local_proxy_with_socks5_upstream(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
// Start a mock HTTP server as the final destination
let (target_port, target_handle) = start_mock_http_server("SOCKS5-TARGET-RESPONSE").await;
println!("Mock target HTTP server on port {target_port}");
// Start a mock SOCKS5 proxy
let (socks_port, socks_handle) = start_mock_socks5_server().await;
println!("Mock SOCKS5 server on port {socks_port}");
// Helper to start a socks5 proxy
async fn start_socks5_proxy(
binary_path: &std::path::PathBuf,
socks_port: u16,
) -> Result<(String, u16), Box<dyn std::error::Error + Send + Sync>> {
let output = TestUtils::execute_command(
binary_path,
&[
"proxy",
"start",
"--host",
"127.0.0.1",
"--proxy-port",
&socks_port.to_string(),
"--type",
"socks5",
],
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Proxy start failed: {stderr}").into());
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
let id = config["id"].as_str().unwrap().to_string();
let port = config["localPort"].as_u64().unwrap() as u16;
// Wait for proxy to be fully ready by verifying it accepts and responds
for _ in 0..20 {
sleep(Duration::from_millis(100)).await;
if TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
break;
}
}
// Extra settle time for the accept loop to be fully initialized
sleep(Duration::from_millis(200)).await;
Ok((id, port))
}
// Test 1: HTTP request through donut-proxy -> SOCKS5 -> target
let (proxy_id, local_port) = start_socks5_proxy(&binary_path, socks_port).await?;
tracker.track_proxy(proxy_id);
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request = format!(
"GET http://127.0.0.1:{target_port}/ HTTP/1.1\r\nHost: 127.0.0.1:{target_port}\r\nConnection: close\r\n\r\n"
);
stream.write_all(request.as_bytes()).await?;
let mut response = vec![0u8; 8192];
let n = tokio::time::timeout(Duration::from_secs(10), stream.read(&mut response))
.await
.map_err(|_| "HTTP request through SOCKS5 timed out")?
.map_err(|e| format!("Read error: {e}"))?;
let response_str = String::from_utf8_lossy(&response[..n]);
assert!(
response_str.contains("SOCKS5-TARGET-RESPONSE"),
"HTTP request should be tunneled through SOCKS5 to target, got: {}",
&response_str[..response_str.len().min(500)]
);
println!("SOCKS5 upstream proxy test passed");
tracker.cleanup_all().await;
target_handle.abort();
socks_handle.abort();
Ok(())
}
+207
View File
@@ -841,3 +841,210 @@ async fn test_profile_bypass_rules_sync() {
client.delete(&test_key, None).await.unwrap();
client.delete(&empty_key, None).await.unwrap();
}
#[tokio::test]
async fn test_encrypted_profile_sync() {
use donutbrowser_lib::sync::encryption::{
decrypt_bytes, derive_profile_key, encrypt_bytes, generate_salt,
};
ensure_sync_server_available().await;
let client = TestClient::new();
let temp_dir = TempDir::new().unwrap();
let profile_id = uuid::Uuid::new_v4().to_string();
let test_key = format!("profiles/{}.tar.gz.enc", profile_id);
let bundle = create_test_profile_bundle(temp_dir.path());
let salt = generate_salt();
let password = "test-e2e-encryption-password";
let key = derive_profile_key(password, &salt).unwrap();
let encrypted = encrypt_bytes(&key, &bundle).unwrap();
assert_ne!(
encrypted, bundle,
"Encrypted data should differ from plaintext"
);
assert!(
encrypted.len() > bundle.len(),
"Encrypted data includes nonce + auth tag overhead"
);
let presign = client
.presign_upload(&test_key, "application/octet-stream")
.await
.unwrap();
client
.upload_bytes(&presign.url, &encrypted, "application/octet-stream")
.await
.unwrap();
let stat = client.stat(&test_key).await.unwrap();
assert!(stat.exists);
assert_eq!(stat.size, Some(encrypted.len() as u64));
let download_presign = client.presign_download(&test_key).await.unwrap();
let downloaded = client.download_bytes(&download_presign.url).await.unwrap();
assert_eq!(downloaded.len(), encrypted.len());
let decrypted = decrypt_bytes(&key, &downloaded).unwrap();
assert_eq!(
decrypted, bundle,
"Decrypted content should match original bundle"
);
let extract_dir = temp_dir.path().join("extracted");
fs::create_dir_all(&extract_dir).unwrap();
let metadata = extract_bundle(&decrypted, &extract_dir);
assert_eq!(metadata["id"], "test-profile-id");
assert_eq!(metadata["name"], "Test Profile");
assert_eq!(metadata["browser"], "chromium");
assert_eq!(metadata["version"], "120.0.0");
assert!(metadata["sync_enabled"].as_bool().unwrap());
let tags = metadata["tags"].as_array().unwrap();
assert_eq!(tags.len(), 2);
assert_eq!(tags[0], "test");
assert_eq!(tags[1], "e2e");
let test_file = extract_dir.join("profile").join("test_file.txt");
assert!(test_file.exists());
assert_eq!(fs::read_to_string(test_file).unwrap(), "test content");
let wrong_key = derive_profile_key("wrong-password", &salt).unwrap();
assert!(
decrypt_bytes(&wrong_key, &downloaded).is_err(),
"Decryption with wrong key should fail"
);
let different_salt = generate_salt();
let wrong_salt_key = derive_profile_key(password, &different_salt).unwrap();
assert!(
decrypt_bytes(&wrong_salt_key, &downloaded).is_err(),
"Decryption with key derived from wrong salt should fail"
);
client.delete(&test_key, None).await.unwrap();
let final_stat = client.stat(&test_key).await.unwrap();
assert!(!final_stat.exists);
}
#[tokio::test]
async fn test_encrypted_delta_sync() {
use donutbrowser_lib::sync::encryption::{
decrypt_bytes, derive_profile_key, encrypt_bytes, generate_salt,
};
ensure_sync_server_available().await;
let client = TestClient::new();
let profile_id = uuid::Uuid::new_v4().to_string();
let salt = generate_salt();
let password = "delta-sync-test-password";
let key = derive_profile_key(password, &salt).unwrap();
let file1_key = format!("profiles/{}/files/file1.txt.enc", profile_id);
let file2_key = format!("profiles/{}/files/file2.txt.enc", profile_id);
let file3_key = format!("profiles/{}/files/file3.txt.enc", profile_id);
let content1 = b"file one content";
let content2 = b"file two content";
let content3 = b"file three content";
let encrypted1 = encrypt_bytes(&key, content1).unwrap();
let encrypted2 = encrypt_bytes(&key, content2).unwrap();
let encrypted3 = encrypt_bytes(&key, content3).unwrap();
let presign1 = client
.presign_upload(&file1_key, "application/octet-stream")
.await
.unwrap();
client
.upload_bytes(&presign1.url, &encrypted1, "application/octet-stream")
.await
.unwrap();
let presign2 = client
.presign_upload(&file2_key, "application/octet-stream")
.await
.unwrap();
client
.upload_bytes(&presign2.url, &encrypted2, "application/octet-stream")
.await
.unwrap();
let presign3 = client
.presign_upload(&file3_key, "application/octet-stream")
.await
.unwrap();
client
.upload_bytes(&presign3.url, &encrypted3, "application/octet-stream")
.await
.unwrap();
for (file_key, expected_content) in [
(&file1_key, content1.as_slice()),
(&file2_key, content2.as_slice()),
(&file3_key, content3.as_slice()),
] {
let dl_presign = client.presign_download(file_key).await.unwrap();
let downloaded = client.download_bytes(&dl_presign.url).await.unwrap();
let decrypted = decrypt_bytes(&key, &downloaded).unwrap();
assert_eq!(
decrypted, expected_content,
"Decrypted content mismatch for {file_key}"
);
}
let stat1_before = client.stat(&file1_key).await.unwrap();
let stat2_before = client.stat(&file2_key).await.unwrap();
let stat3_before = client.stat(&file3_key).await.unwrap();
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let updated_content2 = b"file two content -- updated with new data";
let encrypted2_updated = encrypt_bytes(&key, updated_content2).unwrap();
let presign2_update = client
.presign_upload(&file2_key, "application/octet-stream")
.await
.unwrap();
client
.upload_bytes(
&presign2_update.url,
&encrypted2_updated,
"application/octet-stream",
)
.await
.unwrap();
let stat2_after = client.stat(&file2_key).await.unwrap();
assert_ne!(
stat2_before.size, stat2_after.size,
"File2 size should have changed after update"
);
let stat1_after = client.stat(&file1_key).await.unwrap();
let stat3_after = client.stat(&file3_key).await.unwrap();
assert_eq!(
stat1_before.size, stat1_after.size,
"File1 should be unchanged"
);
assert_eq!(
stat3_before.size, stat3_after.size,
"File3 should be unchanged"
);
let dl_presign2 = client.presign_download(&file2_key).await.unwrap();
let downloaded2 = client.download_bytes(&dl_presign2.url).await.unwrap();
let decrypted2 = decrypt_bytes(&key, &downloaded2).unwrap();
assert_eq!(
decrypted2,
updated_content2.to_vec(),
"Updated file2 should decrypt to new content"
);
client.delete(&file1_key, None).await.unwrap();
client.delete(&file2_key, None).await.unwrap();
client.delete(&file3_key, None).await.unwrap();
}
+9 -4
View File
@@ -1100,6 +1100,7 @@ export function ProfilesDataTable({
isUpdating,
launchingProfiles,
stoppingProfiles,
crossOsUnlocked,
);
// Listen for sync status events
@@ -2016,12 +2017,13 @@ export function ProfilesDataTable({
);
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning || isLaunching || isStopping || isCrossOs;
isRunning || isLaunching || isStopping || isCrossOsBlocked;
const lockedEmail = meta.getProfileLockEmail(profile.id);
const isLocked = meta.isProfileLockedByAnother(profile.id);
@@ -2076,12 +2078,13 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning || isLaunching || isStopping || isCrossOs;
isRunning || isLaunching || isStopping || isCrossOsBlocked;
return (
<TagsCell
@@ -2104,12 +2107,13 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning || isLaunching || isStopping || isCrossOs;
isRunning || isLaunching || isStopping || isCrossOsBlocked;
return (
<NoteCell
@@ -2130,12 +2134,13 @@ export function ProfilesDataTable({
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isCrossOs = isCrossOsProfile(profile);
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning || isLaunching || isStopping || isCrossOs;
isRunning || isLaunching || isStopping || isCrossOsBlocked;
const hasProxyOverride = Object.hasOwn(
meta.proxyOverrides,
+17
View File
@@ -335,6 +335,23 @@ export function useBrowserDownload() {
`download-${browserName.toLowerCase()}-${progress.version}`,
);
setDownloadProgress(null);
} else if (progress.stage === "error") {
setDownloadingBrowsers((prev) => {
const next = new Set(prev);
next.delete(progress.browser);
return next;
});
dismissToast(
`download-${browserName.toLowerCase()}-${progress.version}`,
);
setDownloadProgress(null);
showErrorToast(
`${browserName} ${progress.version}: extraction failed`,
{
description:
"The corrupt file was deleted. It will be re-downloaded on next attempt.",
},
);
} else if (progress.stage === "completed") {
setDownloadingBrowsers((prev) => {
const next = new Set(prev);
+8 -3
View File
@@ -15,6 +15,7 @@ export function useBrowserState(
_isUpdating: (browser: string) => boolean,
launchingProfiles: Set<string>,
stoppingProfiles: Set<string>,
crossOsUnlocked = false,
) {
const [isClient, setIsClient] = useState(false);
@@ -52,7 +53,7 @@ export function useBrowserState(
(profile: BrowserProfile): boolean => {
if (!isClient) return false;
if (isCrossOsProfile(profile)) return false;
if (isCrossOsProfile(profile) && !crossOsUnlocked) return false;
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
@@ -80,6 +81,7 @@ export function useBrowserState(
isAnyInstanceRunning,
launchingProfiles,
stoppingProfiles,
crossOsUnlocked,
],
);
@@ -157,8 +159,10 @@ export function useBrowserState(
if (!isClient) return "Loading...";
if (isCrossOsProfile(profile) && profile.host_os) {
const osName = getOSDisplayName(profile.host_os);
return `This profile was created on ${osName} and is not supported on this system`;
if (!crossOsUnlocked) {
const osName = getOSDisplayName(profile.host_os);
return `This profile was created on ${osName}. A paid subscription is required to launch cross-OS profiles.`;
}
}
const isRunning = runningProfiles.has(profile.id);
@@ -193,6 +197,7 @@ export function useBrowserState(
canLaunchProfile,
launchingProfiles,
stoppingProfiles,
crossOsUnlocked,
],
);
+2 -2
View File
@@ -296,8 +296,8 @@
"formatTextHint": "Expects text like: host:port:username:password or protocol://user:pass@host:port",
"testUrl": "Test URL",
"testing": "Testing...",
"testSuccess": "Dynamic proxy resolved to {{host}}:{{port}}",
"testFailed": "Failed to fetch proxy: {{error}}",
"testSuccess": "Proxy working: {{host}}:{{port}}",
"testFailed": "Proxy test failed: {{error}}",
"fetchFailed": "Failed to fetch dynamic proxy: {{error}}"
},
"check": {
+2 -2
View File
@@ -296,8 +296,8 @@
"formatTextHint": "Espera texto como: host:port:username:password o protocol://user:pass@host:port",
"testUrl": "Probar URL",
"testing": "Probando...",
"testSuccess": "El proxy dinámico se resolvió a {{host}}:{{port}}",
"testFailed": "Error al obtener el proxy: {{error}}",
"testSuccess": "Proxy funcionando: {{host}}:{{port}}",
"testFailed": "Prueba de proxy fallida: {{error}}",
"fetchFailed": "Error al obtener el proxy dinámico: {{error}}"
},
"check": {
+2 -2
View File
@@ -296,8 +296,8 @@
"formatTextHint": "Attend du texte comme : host:port:username:password ou protocol://user:pass@host:port",
"testUrl": "Tester l'URL",
"testing": "Test en cours...",
"testSuccess": "Le proxy dynamique a été résolu en {{host}}:{{port}}",
"testFailed": "Échec de la récupération du proxy : {{error}}",
"testSuccess": "Proxy fonctionnel : {{host}}:{{port}}",
"testFailed": "Échec du test de proxy : {{error}}",
"fetchFailed": "Échec de la récupération du proxy dynamique : {{error}}"
},
"check": {
+2 -2
View File
@@ -296,8 +296,8 @@
"formatTextHint": "テキスト形式: host:port:username:password または protocol://user:pass@host:port",
"testUrl": "URLをテスト",
"testing": "テスト中...",
"testSuccess": "ダイナミックプロキシは {{host}}:{{port}} に解決されました",
"testFailed": "プロキシの取得に失敗しました: {{error}}",
"testSuccess": "プロキシ動作中: {{host}}:{{port}}",
"testFailed": "プロキシテスト失敗: {{error}}",
"fetchFailed": "ダイナミックプロキシの取得に失敗しました: {{error}}"
},
"check": {
+2 -2
View File
@@ -296,8 +296,8 @@
"formatTextHint": "Espera texto como: host:port:username:password ou protocol://user:pass@host:port",
"testUrl": "Testar URL",
"testing": "Testando...",
"testSuccess": "O proxy dinâmico foi resolvido para {{host}}:{{port}}",
"testFailed": "Falha ao obter o proxy: {{error}}",
"testSuccess": "Proxy funcionando: {{host}}:{{port}}",
"testFailed": "Falha no teste de proxy: {{error}}",
"fetchFailed": "Falha ao obter o proxy dinâmico: {{error}}"
},
"check": {
+2 -2
View File
@@ -296,8 +296,8 @@
"formatTextHint": "Ожидается текст вида: host:port:username:password или protocol://user:pass@host:port",
"testUrl": "Проверить URL",
"testing": "Проверка...",
"testSuccess": "Динамический прокси разрешён в {{host}}:{{port}}",
"testFailed": "Не удалось получить прокси: {{error}}",
"testSuccess": "Прокси работает: {{host}}:{{port}}",
"testFailed": "Тест прокси не пройден: {{error}}",
"fetchFailed": "Не удалось получить динамический прокси: {{error}}"
},
"check": {
+2 -2
View File
@@ -296,8 +296,8 @@
"formatTextHint": "期望文本格式: host:port:username:password 或 protocol://user:pass@host:port",
"testUrl": "测试URL",
"testing": "测试中...",
"testSuccess": "动态代理已解析为 {{host}}:{{port}}",
"testFailed": "获取代理失败: {{error}}",
"testSuccess": "代理正常运行: {{host}}:{{port}}",
"testFailed": "代理测试失败: {{error}}",
"fetchFailed": "获取动态代理失败: {{error}}"
},
"check": {