Compare commits

..

61 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
zhom bf6ef24902 chore: version bump 2026-04-11 23:37:52 +04:00
zhom 258ea047b6 refactor: vpn 2026-04-11 23:37:05 +04:00
zhom c62ac6288e refactor: save port 2026-04-11 18:54:26 +04:00
zhom 2b583d1844 chore: linting 2026-04-11 17:49:36 +04:00
zhom cff3f521c1 style: copy 2026-04-11 17:12:21 +04:00
zhom 404e12dc2d chore: overwrite aws cli 2026-04-11 17:12:21 +04:00
andy f9de75db0a Merge pull request #281 from zhom/dependabot/cargo/src-tauri/rust-dependencies-2e745994f0
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 18 updates
2026-04-11 17:11:30 +04:00
andy 83b7bf2e2f Merge pull request #280 from zhom/dependabot/github_actions/github-actions-32c319ba9f
ci(deps): bump the github-actions group with 3 updates
2026-04-11 17:11:11 +04:00
andy d81add6979 Merge pull request #279 from zhom/dependabot/npm_and_yarn/next-16.2.3
deps(deps): bump next from 16.2.2 to 16.2.3
2026-04-11 17:11:02 +04:00
dependabot[bot] 5cf5389aad deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 18 updates:

| Package | From | To |
| --- | --- | --- |
| [tauri-plugin-fs](https://github.com/tauri-apps/plugins-workspace) | `2.4.5` | `2.5.0` |
| [tauri-plugin-deep-link](https://github.com/tauri-apps/plugins-workspace) | `2.4.7` | `2.4.8` |
| [tauri-plugin-single-instance](https://github.com/tauri-apps/plugins-workspace) | `2.4.0` | `2.4.1` |
| [tauri-plugin-dialog](https://github.com/tauri-apps/plugins-workspace) | `2.6.0` | `2.7.0` |
| [tokio](https://github.com/tokio-rs/tokio) | `1.51.0` | `1.51.1` |
| [zip](https://github.com/zip-rs/zip2) | `8.5.0` | `8.5.1` |
| [bzip2](https://github.com/trifectatechfoundation/bzip2-rs) | `0.5.2` | `0.6.1` |
| [aes](https://github.com/RustCrypto/block-ciphers) | `0.8.4` | `0.9.0` |
| [cbc](https://github.com/RustCrypto/block-modes) | `0.1.2` | `0.2.0` |
| [sha1](https://github.com/RustCrypto/hashes) | `0.10.6` | `0.11.0` |
| [sha2](https://github.com/RustCrypto/hashes) | `0.10.9` | `0.11.0` |
| [async-signal](https://github.com/smol-rs/async-signal) | `0.2.13` | `0.2.14` |
| [block-padding](https://github.com/RustCrypto/utils) | `0.3.3` | `0.4.2` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.59` | `1.2.60` |
| [fastrand](https://github.com/smol-rs/fastrand) | `2.3.0` | `2.4.1` |
| [gif](https://github.com/image-rs/image-gif) | `0.14.1` | `0.14.2` |
| libredox | `0.1.15` | `0.1.16` |
| [rustls-webpki](https://github.com/rustls/webpki) | `0.103.10` | `0.103.11` |


Updates `tauri-plugin-fs` from 2.4.5 to 2.5.0
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.4.5...fs-v2.5.0)

Updates `tauri-plugin-deep-link` from 2.4.7 to 2.4.8
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/deep-link-v2.4.7...deep-link-v2.4.8)

Updates `tauri-plugin-single-instance` from 2.4.0 to 2.4.1
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.4.0...fs-v2.4.1)

Updates `tauri-plugin-dialog` from 2.6.0 to 2.7.0
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/log-v2.6.0...log-v2.7.0)

Updates `tokio` from 1.51.0 to 1.51.1
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.51.0...tokio-1.51.1)

Updates `zip` from 8.5.0 to 8.5.1
- [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/v8.5.0...v8.5.1)

Updates `bzip2` from 0.5.2 to 0.6.1
- [Release notes](https://github.com/trifectatechfoundation/bzip2-rs/releases)
- [Commits](https://github.com/trifectatechfoundation/bzip2-rs/compare/v0.5.2...v0.6.1)

Updates `aes` from 0.8.4 to 0.9.0
- [Commits](https://github.com/RustCrypto/block-ciphers/compare/aes-v0.8.4...aes-v0.9.0)

Updates `cbc` from 0.1.2 to 0.2.0
- [Commits](https://github.com/RustCrypto/block-modes/compare/cbc-v0.1.2...cbc-v0.2.0)

Updates `sha1` from 0.10.6 to 0.11.0
- [Commits](https://github.com/RustCrypto/hashes/compare/sha1-v0.10.6...sha1-v0.11.0)

Updates `sha2` from 0.10.9 to 0.11.0
- [Commits](https://github.com/RustCrypto/hashes/compare/sha2-v0.10.9...sha2-v0.11.0)

Updates `async-signal` from 0.2.13 to 0.2.14
- [Release notes](https://github.com/smol-rs/async-signal/releases)
- [Changelog](https://github.com/smol-rs/async-signal/blob/master/CHANGELOG.md)
- [Commits](https://github.com/smol-rs/async-signal/compare/v0.2.13...v0.2.14)

Updates `block-padding` from 0.3.3 to 0.4.2
- [Commits](https://github.com/RustCrypto/utils/compare/block-padding-v0.3.3...block-padding-v0.4.2)

Updates `cc` from 1.2.59 to 1.2.60
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.2.59...cc-v1.2.60)

Updates `fastrand` from 2.3.0 to 2.4.1
- [Release notes](https://github.com/smol-rs/fastrand/releases)
- [Changelog](https://github.com/smol-rs/fastrand/blob/master/CHANGELOG.md)
- [Commits](https://github.com/smol-rs/fastrand/compare/v2.3.0...v2.4.1)

Updates `gif` from 0.14.1 to 0.14.2
- [Changelog](https://github.com/image-rs/image-gif/blob/master/Changes.md)
- [Commits](https://github.com/image-rs/image-gif/compare/v0.14.1...v0.14.2)

Updates `libredox` from 0.1.15 to 0.1.16

Updates `rustls-webpki` from 0.103.10 to 0.103.11
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](https://github.com/rustls/webpki/compare/v/0.103.10...v/0.103.11)

---
updated-dependencies:
- dependency-name: tauri-plugin-fs
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-deep-link
  dependency-version: 2.4.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-single-instance
  dependency-version: 2.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin-dialog
  dependency-version: 2.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tokio
  dependency-version: 1.51.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zip
  dependency-version: 8.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: bzip2
  dependency-version: 0.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: aes
  dependency-version: 0.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: cbc
  dependency-version: 0.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: sha1
  dependency-version: 0.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: sha2
  dependency-version: 0.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: async-signal
  dependency-version: 0.2.14
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: block-padding
  dependency-version: 0.4.2
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.60
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: fastrand
  dependency-version: 2.4.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: gif
  dependency-version: 0.14.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libredox
  dependency-version: 0.1.16
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rustls-webpki
  dependency-version: 0.103.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-11 09:48:02 +00:00
dependabot[bot] 943b3b849a 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), [docker/build-push-action](https://github.com/docker/build-push-action) and [anomalyco/opencode](https://github.com/anomalyco/opencode).


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

Updates `docker/build-push-action` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/d08e5c354a6adb9ed34480a06d141179aa583294...bcafcacb16a39f128d818304e6c9c0c18556b85f)

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

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.4.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-11 09:04:47 +00:00
dependabot[bot] f54b6ad2d2 deps(deps): bump next from 16.2.2 to 16.2.3
Bumps [next](https://github.com/vercel/next.js) from 16.2.2 to 16.2.3.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.2...v16.2.3)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-11 04:59:01 +00:00
github-actions[bot] 4da80dd2db chore: update flake.nix for v0.20.3 [skip ci] (#278)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-10 07:50:18 +00:00
github-actions[bot] 17a9b7c3f2 docs: update CHANGELOG.md and README.md for v0.20.3 [skip ci] (#277)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-10 07:50:02 +00:00
zhom 001bda2efd chore: versiom bump 2026-04-10 01:24:06 +04:00
zhom ff401fd4d3 refactor: debug wayfern launch 2026-04-10 01:08:12 +04:00
zhom 82a2efa7f2 chore: serialize changelog and flake jobs 2026-04-08 20:57:56 +04:00
andy 9fe973039d Merge pull request #274 from zhom/docs/release-0.20.2
docs: release notes for v0.20.2
2026-04-08 20:34:55 +04:00
github-actions[bot] 2cdbdaa1ab chore: update flake.nix for v0.20.2 [skip ci] (#273)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-08 16:28:46 +00:00
github-actions[bot] d31b22f57d docs: update CHANGELOG.md and README.md for v0.20.2 [skip ci] 2026-04-08 16:28:43 +00:00
zhom 45e57662de chore: version bump 2026-04-08 18:53:18 +04:00
zhom 7931a241e7 chore: aws integrity checks 2026-04-08 18:52:39 +04:00
zhom 224c35388f chore: inject NEXT_PUBLIC_TURNSTILE everywhere 2026-04-08 18:52:08 +04:00
github-actions[bot] 2bf45357ab chore: update flake.nix for v0.20.1 [skip ci] (#272)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-08 12:10:48 +00:00
github-actions[bot] dd0ccda5fd docs: update CHANGELOG.md and README.md for v0.20.1 [skip ci] (#271)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-08 12:10:42 +00:00
zhom c422217b0f chore: version bump 2026-04-08 14:35:08 +04:00
zhom 55b0016d31 chore: normalize r2 endpoint 2026-04-08 14:34:13 +04:00
zhom fede1d93a8 chore: pull turnstile public key in frontend at build time 2026-04-08 14:33:01 +04:00
github-actions[bot] 17ee38d316 chore: update flake.nix for v0.20.0 [skip ci] (#270)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-08 10:24:25 +00:00
github-actions[bot] 826cb187c7 docs: update CHANGELOG.md and README.md for v0.20.0 [skip ci] (#269)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-08 10:24:14 +00:00
zhom 0deea7eb0c chore: version bump 2026-04-08 12:49:36 +04:00
zhom 3f1f11001e refactor: cleanup 2026-04-08 12:48:42 +04:00
zhom a0205aafa9 fix: cookie copying for wayfern 2026-04-08 11:30:53 +04:00
zhom 7d03968123 refactor: dynamic proxy 2026-04-08 10:37:43 +04:00
zhom 05791ace1f chore: linting 2026-04-05 23:04:09 +04:00
zhom 80757829c2 chore: linting 2026-04-05 13:39:36 +04:00
83 changed files with 4035 additions and 1862 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@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
with:
run_install: false
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
echo "Tags: ${TAGS}"
- name: Build and push Docker image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 #v7.0.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f #v7.1.0
with:
context: .
file: ./donut-sync/Dockerfile
+1 -1
View File
@@ -327,7 +327,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Run opencode
uses: anomalyco/opencode/github@6314f09c14fdd6a3ab8bedc4f7b7182647551d12 #v1.3.13
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@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.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@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
with:
run_install: false
+27 -5
View File
@@ -40,10 +40,36 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
fi
- name: Configure aws-cli for R2
# aws-cli v2.23+ sends integrity checksums by default; Cloudflare R2
# rejects those headers with `Unauthorized` on ListObjectsV2.
# Also normalise the endpoint URL (must start with https://).
# Both values propagate to later steps via $GITHUB_ENV.
env:
RAW_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
run: |
endpoint="$RAW_ENDPOINT"
if [[ "$endpoint" != https://* && "$endpoint" != http://* ]]; then
endpoint="https://$endpoint"
fi
echo "R2_ENDPOINT=$endpoint" >> "$GITHUB_ENV"
echo "AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED" >> "$GITHUB_ENV"
echo "AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED" >> "$GITHUB_ENV"
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y dpkg-dev createrepo-c
sudo apt-get install -y dpkg-dev createrepo-c python3-pip
# 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:
@@ -67,7 +93,6 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
DEB_DIR="/tmp/repo/deb"
@@ -131,7 +156,6 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
RPM_DIR="/tmp/repo/rpm"
@@ -164,7 +188,6 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
echo "Uploading DEB repository..."
@@ -182,7 +205,6 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
+13 -3
View File
@@ -108,7 +108,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
with:
run_install: false
@@ -139,6 +139,10 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build frontend
# NEXT_PUBLIC_* vars are inlined at build time and must be forwarded
# from secrets explicitly — they are NOT inherited from the job env.
env:
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
run: pnpm exec next build
- name: Verify frontend dist exists
@@ -216,6 +220,12 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# tauri-action invokes `pnpm tauri build`, which runs
# `beforeBuildCommand` from tauri.conf.json. That rebuilds the
# frontend in its own subprocess, so the env var MUST be forwarded
# here or the inner `next build` inlines an empty string and
# overwrites the dist the explicit "Build frontend" step produced.
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
with:
projectPath: ./src-tauri
tagName: ${{ github.ref_name }}
@@ -482,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:
@@ -535,7 +545,7 @@ jobs:
update-flake:
if: github.repository == 'zhom/donutbrowser'
needs: [release]
needs: [release, changelog]
runs-on: ubuntu-latest
permissions:
contents: write
+8 -1
View File
@@ -107,7 +107,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
with:
run_install: false
@@ -138,6 +138,10 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build frontend
# NEXT_PUBLIC_* vars are inlined at build time and must be forwarded
# from secrets explicitly — they are NOT inherited from the job env.
env:
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
run: pnpm exec next build
- name: Verify frontend dist exists
@@ -226,6 +230,9 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# tauri-action's inner `pnpm tauri build` re-runs beforeBuildCommand
# which rebuilds dist/ in a subprocess. The env var must be here too.
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
with:
projectPath: ./src-tauri
tagName: "nightly-${{ steps.timestamp.outputs.timestamp }}"
+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
+4 -4
View File
@@ -35,7 +35,7 @@ jobs:
uses: actions/checkout@v6.0.2
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
with:
run_install: false
@@ -85,7 +85,7 @@ jobs:
# Wait for MinIO to be ready
for i in {1..30}; do
if curl -sf http://localhost:8987/minio/health/live; then
if curl -sf http://127.0.0.1:8987/minio/health/live; then
echo "MinIO is ready"
break
fi
@@ -94,7 +94,7 @@ jobs:
done
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
uses: pnpm/action-setup@078e9d416474b29c0c387560859308974f7e9c53 #v6.0.1
with:
run_install: false
@@ -111,7 +111,7 @@ jobs:
working-directory: donut-sync
env:
SYNC_TOKEN: test-sync-token
S3_ENDPOINT: http://localhost:8987
S3_ENDPOINT: http://127.0.0.1:8987
S3_ACCESS_KEY_ID: minioadmin
S3_SECRET_ACCESS_KEY: minioadmin
S3_BUCKET: donut-sync-test
+1 -1
View File
@@ -58,4 +58,4 @@ nodecar/nodecar-bin
.env
# next
next-env.d.ts
**/next-env.d.ts
+117
View File
@@ -1,6 +1,123 @@
# 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
- debug wayfern launch
### Maintenance
- chore: version bump
- chore: serialize changelog and flake jobs
- chore: update flake.nix for v0.20.2 [skip ci] (#273)
## v0.20.2 (2026-04-08)
### Maintenance
- chore: version bump
- chore: aws integrity checks
- chore: inject NEXT_PUBLIC_TURNSTILE everywhere
- chore: update flake.nix for v0.20.1 [skip ci] (#272)
## v0.20.1 (2026-04-08)
### Maintenance
- chore: version bump
- chore: normalize r2 endpoint
- chore: pull turnstile public key in frontend at build time
- chore: update flake.nix for v0.20.0 [skip ci] (#270)
## v0.20.0 (2026-04-08)
### Bug Fixes
- cookie copying for wayfern
### Refactoring
- cleanup
- dynamic proxy
### Documentation
- update CHANGELOG.md and README.md for v0.19.0 [skip ci] (#261)
### Maintenance
- chore: version bump
- chore: linting
- chore: linting
- chore: linting
- chore: update flake.nix for v0.19.0 [skip ci] (#262)
### Other
- deps(rust)(deps): bump the rust-dependencies group
- deps(deps): bump the frontend-dependencies group with 19 updates
## v0.19.0 (2026-04-04)
### Features
+5 -5
View File
@@ -51,7 +51,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.19.0/Donut_0.19.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.19.0/Donut_0.19.0_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.19.0/Donut_0.19.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.19.0/Donut_0.19.0_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.19.0/Donut_0.19.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.19.0/Donut_0.19.0_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.19.0/Donut-0.19.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.19.0/Donut-0.19.0-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.19.0/Donut_0.19.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.19.0/Donut_0.19.0_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:
-1
View File
@@ -18,4 +18,3 @@ services:
volumes:
minio_data:
+14 -3
View File
@@ -2,18 +2,29 @@ import { INestApplication } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import request from "supertest";
import { App } from "supertest/types";
import { AppModule } from "./../src/app.module.js";
import { AppController } from "./../src/app.controller.js";
import { AppService } from "./../src/app.service.js";
import { SyncService } from "./../src/sync/sync.service.js";
describe("AppController (e2e)", () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
controllers: [AppController],
providers: [
AppService,
{
provide: SyncService,
useValue: {
checkS3Connectivity: async () => true,
},
},
],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
await app.listen(0);
});
afterEach(async () => {
+7 -1
View File
@@ -1,10 +1,16 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"maxWorkers": 1,
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
"^.+\\.(t|j)s$": [
"ts-jest",
{
"tsconfig": "<rootDir>/tsconfig.json"
}
]
},
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
+41 -40
View File
@@ -1,3 +1,5 @@
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import { INestApplication } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
@@ -6,6 +8,11 @@ import { App } from "supertest/types";
import { AppController } from "./../src/app.controller.js";
import { AppService } from "./../src/app.service.js";
import { SyncModule } from "./../src/sync/sync.module.js";
import {
configureTestEnv,
TEST_SYNC_TOKEN,
waitForTestS3,
} from "./test-env.js";
interface PresignResponse {
url: string;
@@ -29,26 +36,12 @@ interface StatResponse {
lastModified?: string;
}
interface SSEError {
code?: string;
timeout?: boolean;
response?: { status: number };
}
const TEST_TOKEN = "test-sync-token";
describe("SyncController (e2e)", () => {
let app: INestApplication<App>;
beforeAll(async () => {
process.env.SYNC_TOKEN = TEST_TOKEN;
process.env.S3_ENDPOINT =
process.env.S3_ENDPOINT || "http://localhost:8987";
process.env.S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID || "minioadmin";
process.env.S3_SECRET_ACCESS_KEY =
process.env.S3_SECRET_ACCESS_KEY || "minioadmin";
process.env.S3_BUCKET = "donut-sync-test";
process.env.S3_FORCE_PATH_STYLE = "true";
configureTestEnv();
await waitForTestS3();
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [
@@ -62,7 +55,7 @@ describe("SyncController (e2e)", () => {
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
await app.listen(0);
});
afterAll(async () => {
@@ -88,7 +81,7 @@ describe("SyncController (e2e)", () => {
it("should accept requests with valid token", () => {
return request(app.getHttpServer())
.post("/v1/objects/stat")
.set("Authorization", `Bearer ${TEST_TOKEN}`)
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: "nonexistent-key" })
.expect(200)
.expect({ exists: false });
@@ -99,7 +92,7 @@ describe("SyncController (e2e)", () => {
it("should return exists: false for non-existent key", () => {
return request(app.getHttpServer())
.post("/v1/objects/stat")
.set("Authorization", `Bearer ${TEST_TOKEN}`)
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: "does-not-exist" })
.expect(200)
.expect({ exists: false });
@@ -110,7 +103,7 @@ describe("SyncController (e2e)", () => {
it("should return a presigned upload URL", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/presign-upload")
.set("Authorization", `Bearer ${TEST_TOKEN}`)
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: "test/upload-key.txt", contentType: "text/plain" })
.expect(200);
@@ -125,7 +118,7 @@ describe("SyncController (e2e)", () => {
it("should return a presigned download URL", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/presign-download")
.set("Authorization", `Bearer ${TEST_TOKEN}`)
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: "test/download-key.txt" })
.expect(200);
@@ -140,7 +133,7 @@ describe("SyncController (e2e)", () => {
it("should list objects with prefix", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/list")
.set("Authorization", `Bearer ${TEST_TOKEN}`)
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ prefix: "profiles/" })
.expect(200);
@@ -155,7 +148,7 @@ describe("SyncController (e2e)", () => {
it("should delete object and create tombstone", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/delete")
.set("Authorization", `Bearer ${TEST_TOKEN}`)
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({
key: "test/to-delete.txt",
tombstoneKey: "tombstones/test/to-delete.json",
@@ -176,7 +169,7 @@ describe("SyncController (e2e)", () => {
it("should complete full upload/download cycle with presigned URLs", async () => {
const uploadResponse = await request(app.getHttpServer())
.post("/v1/objects/presign-upload")
.set("Authorization", `Bearer ${TEST_TOKEN}`)
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: testKey, contentType: "text/plain" })
.expect(200);
@@ -192,7 +185,7 @@ describe("SyncController (e2e)", () => {
const statResponse = await request(app.getHttpServer())
.post("/v1/objects/stat")
.set("Authorization", `Bearer ${TEST_TOKEN}`)
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: testKey })
.expect(200);
@@ -202,7 +195,7 @@ describe("SyncController (e2e)", () => {
const downloadResponse = await request(app.getHttpServer())
.post("/v1/objects/presign-download")
.set("Authorization", `Bearer ${TEST_TOKEN}`)
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: testKey })
.expect(200);
@@ -215,13 +208,13 @@ describe("SyncController (e2e)", () => {
await request(app.getHttpServer())
.post("/v1/objects/delete")
.set("Authorization", `Bearer ${TEST_TOKEN}`)
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: testKey })
.expect(200);
const finalStatResponse = await request(app.getHttpServer())
.post("/v1/objects/stat")
.set("Authorization", `Bearer ${TEST_TOKEN}`)
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: testKey })
.expect(200);
@@ -238,20 +231,28 @@ describe("SyncController (e2e)", () => {
});
it("should return SSE stream with valid token", async () => {
const response = await request(app.getHttpServer())
.get("/v1/objects/subscribe")
.set("Authorization", `Bearer ${TEST_TOKEN}`)
.set("Accept", "text/event-stream")
.buffer(true)
.timeout(3000)
.catch((err: SSEError) => {
if (err.code === "ECONNABORTED" || err.timeout) {
return err.response ?? { status: 200 };
}
throw err;
});
const address = (
app.getHttpServer() as Server
).address() as AddressInfo | null;
if (!address || typeof address === "string") {
throw new Error("Expected app to be listening on a TCP port");
}
const response = await fetch(
`http://127.0.0.1:${address.port}/v1/objects/subscribe`,
{
headers: {
Accept: "text/event-stream",
Authorization: `Bearer ${TEST_SYNC_TOKEN}`,
},
},
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain(
"text/event-stream",
);
await response.body?.cancel();
});
});
});
+37
View File
@@ -0,0 +1,37 @@
import { ListBucketsCommand, S3Client } from "@aws-sdk/client-s3";
export const TEST_SYNC_TOKEN = "test-sync-token";
export const TEST_S3_ENDPOINT = "http://127.0.0.1:8987";
export function configureTestEnv() {
process.env.SYNC_TOKEN ||= TEST_SYNC_TOKEN;
process.env.S3_ENDPOINT ||= TEST_S3_ENDPOINT;
process.env.S3_ACCESS_KEY_ID ||= "minioadmin";
process.env.S3_SECRET_ACCESS_KEY ||= "minioadmin";
process.env.S3_BUCKET ||= "donut-sync-test";
process.env.S3_FORCE_PATH_STYLE ||= "true";
}
export async function waitForTestS3(timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs;
const s3Client = new S3Client({
endpoint: TEST_S3_ENDPOINT,
region: "us-east-1",
credentials: {
accessKeyId: "minioadmin",
secretAccessKey: "minioadmin",
},
forcePathStyle: true,
});
while (Date.now() < deadline) {
try {
await s3Client.send(new ListBucketsCommand({}));
return;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for S3 at ${TEST_S3_ENDPOINT}`);
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": ".."
}
}
+5 -5
View File
@@ -94,17 +94,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.19.0";
releaseVersion = "0.21.0";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.19.0/Donut_0.19.0_amd64.AppImage";
hash = "sha256-JD/FCjHlq7j7HDZ5gPh6ZXaJpC66UQ1ysX0M0IWXOtY=";
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.19.0/Donut_0.19.0_aarch64.AppImage";
hash = "sha256-tHNuQadVV9f1vMk17Z4VOuJhEL//MLxQFeA2JIQRMjg=";
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 "./dist/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+5 -5
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.19.0",
"version": "0.21.1",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
@@ -48,8 +48,8 @@
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "~2.10.1",
"@tauri-apps/plugin-deep-link": "^2.4.7",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "~2.4.5",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "~2.5.0",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"ahooks": "^3.9.7",
@@ -61,7 +61,7 @@
"i18next": "^26.0.3",
"lucide-react": "^1.7.0",
"motion": "^12.38.0",
"next": "^16.2.2",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
@@ -95,7 +95,7 @@
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0"
}
},
"packageManager": "pnpm@10.30.1",
"packageManager": "pnpm@10.33.0",
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --fix"
+81 -71
View File
@@ -58,11 +58,11 @@ importers:
specifier: ^2.4.7
version: 2.4.7
'@tauri-apps/plugin-dialog':
specifier: ^2.6.0
version: 2.6.0
specifier: ^2.7.0
version: 2.7.0
'@tauri-apps/plugin-fs':
specifier: ~2.4.5
version: 2.4.5
specifier: ~2.5.0
version: 2.5.0
'@tauri-apps/plugin-log':
specifier: ^2.8.0
version: 2.8.0
@@ -97,8 +97,8 @@ importers:
specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next:
specifier: ^16.2.2
version: 16.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
specifier: ^16.2.3
version: 16.2.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -696,8 +696,8 @@ packages:
'@emnapi/core@1.9.1':
resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==}
'@emnapi/runtime@1.9.1':
resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==}
'@emnapi/runtime@1.9.2':
resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
'@emnapi/wasi-threads@1.2.0':
resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==}
@@ -1366,57 +1366,57 @@ packages:
'@nestjs/platform-express':
optional: true
'@next/env@16.2.2':
resolution: {integrity: sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==}
'@next/env@16.2.3':
resolution: {integrity: sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==}
'@next/swc-darwin-arm64@16.2.2':
resolution: {integrity: sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==}
'@next/swc-darwin-arm64@16.2.3':
resolution: {integrity: sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.2.2':
resolution: {integrity: sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==}
'@next/swc-darwin-x64@16.2.3':
resolution: {integrity: sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.2.2':
resolution: {integrity: sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==}
'@next/swc-linux-arm64-gnu@16.2.3':
resolution: {integrity: sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.2.2':
resolution: {integrity: sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==}
'@next/swc-linux-arm64-musl@16.2.3':
resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.2.2':
resolution: {integrity: sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==}
'@next/swc-linux-x64-gnu@16.2.3':
resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.2.2':
resolution: {integrity: sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==}
'@next/swc-linux-x64-musl@16.2.3':
resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.2.2':
resolution: {integrity: sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==}
'@next/swc-win32-arm64-msvc@16.2.3':
resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.2.2':
resolution: {integrity: sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==}
'@next/swc-win32-x64-msvc@16.2.3':
resolution: {integrity: sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -2755,11 +2755,11 @@ packages:
'@tauri-apps/plugin-deep-link@2.4.7':
resolution: {integrity: sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg==}
'@tauri-apps/plugin-dialog@2.6.0':
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
'@tauri-apps/plugin-dialog@2.7.0':
resolution: {integrity: sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==}
'@tauri-apps/plugin-fs@2.4.5':
resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==}
'@tauri-apps/plugin-fs@2.5.0':
resolution: {integrity: sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==}
'@tauri-apps/plugin-log@2.8.0':
resolution: {integrity: sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==}
@@ -3262,8 +3262,8 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.13:
resolution: {integrity: sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==}
baseline-browser-mapping@2.10.17:
resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -3340,8 +3340,8 @@ packages:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
caniuse-lite@1.0.30001784:
resolution: {integrity: sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==}
caniuse-lite@1.0.30001787:
resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
@@ -4587,8 +4587,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@16.2.2:
resolution: {integrity: sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==}
next@16.2.3:
resolution: {integrity: sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -4748,6 +4748,10 @@ packages:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.9:
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
engines: {node: ^10 || ^12 || >=14}
pretty-format@30.3.0:
resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -5220,8 +5224,8 @@ packages:
resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
engines: {node: '>=18'}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
tmpl@1.0.5:
@@ -6317,7 +6321,7 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.9.1':
'@emnapi/runtime@1.9.2':
dependencies:
tslib: 2.8.1
optional: true
@@ -6507,7 +6511,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.9.1
'@emnapi/runtime': 1.9.2
optional: true
'@img/sharp-win32-arm64@0.34.5':
@@ -6889,7 +6893,7 @@ snapshots:
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.9.1
'@emnapi/runtime': 1.9.1
'@emnapi/runtime': 1.9.2
'@tybys/wasm-util': 0.10.1
optional: true
@@ -6995,30 +6999,30 @@ snapshots:
optionalDependencies:
'@nestjs/platform-express': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)
'@next/env@16.2.2': {}
'@next/env@16.2.3': {}
'@next/swc-darwin-arm64@16.2.2':
'@next/swc-darwin-arm64@16.2.3':
optional: true
'@next/swc-darwin-x64@16.2.2':
'@next/swc-darwin-x64@16.2.3':
optional: true
'@next/swc-linux-arm64-gnu@16.2.2':
'@next/swc-linux-arm64-gnu@16.2.3':
optional: true
'@next/swc-linux-arm64-musl@16.2.2':
'@next/swc-linux-arm64-musl@16.2.3':
optional: true
'@next/swc-linux-x64-gnu@16.2.2':
'@next/swc-linux-x64-gnu@16.2.3':
optional: true
'@next/swc-linux-x64-musl@16.2.2':
'@next/swc-linux-x64-musl@16.2.3':
optional: true
'@next/swc-win32-arm64-msvc@16.2.2':
'@next/swc-win32-arm64-msvc@16.2.3':
optional: true
'@next/swc-win32-x64-msvc@16.2.2':
'@next/swc-win32-x64-msvc@16.2.3':
optional: true
'@noble/hashes@1.8.0': {}
@@ -8393,11 +8397,11 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-dialog@2.6.0':
'@tauri-apps/plugin-dialog@2.7.0':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-fs@2.4.5':
'@tauri-apps/plugin-fs@2.5.0':
dependencies:
'@tauri-apps/api': 2.10.1
@@ -8915,7 +8919,7 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.13: {}
baseline-browser-mapping@2.10.17: {}
bl@4.1.0:
dependencies:
@@ -8958,8 +8962,8 @@ snapshots:
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.13
caniuse-lite: 1.0.30001784
baseline-browser-mapping: 2.10.17
caniuse-lite: 1.0.30001787
electron-to-chromium: 1.5.331
node-releases: 2.0.37
update-browserslist-db: 1.2.3(browserslist@4.28.2)
@@ -9003,7 +9007,7 @@ snapshots:
camelcase@6.3.0: {}
caniuse-lite@1.0.30001784: {}
caniuse-lite@1.0.30001787: {}
chalk@4.1.2:
dependencies:
@@ -10350,25 +10354,25 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
next@16.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
next@16.2.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.2.2
'@next/env': 16.2.3
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.13
caniuse-lite: 1.0.30001784
baseline-browser-mapping: 2.10.17
caniuse-lite: 1.0.30001787
postcss: 8.4.31
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
styled-jsx: 5.1.6(react@19.2.4)
optionalDependencies:
'@next/swc-darwin-arm64': 16.2.2
'@next/swc-darwin-x64': 16.2.2
'@next/swc-linux-arm64-gnu': 16.2.2
'@next/swc-linux-arm64-musl': 16.2.2
'@next/swc-linux-x64-gnu': 16.2.2
'@next/swc-linux-x64-musl': 16.2.2
'@next/swc-win32-arm64-msvc': 16.2.2
'@next/swc-win32-x64-msvc': 16.2.2
'@next/swc-darwin-arm64': 16.2.3
'@next/swc-darwin-x64': 16.2.3
'@next/swc-linux-arm64-gnu': 16.2.3
'@next/swc-linux-arm64-musl': 16.2.3
'@next/swc-linux-x64-gnu': 16.2.3
'@next/swc-linux-x64-musl': 16.2.3
'@next/swc-win32-arm64-msvc': 16.2.3
'@next/swc-win32-x64-msvc': 16.2.3
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
@@ -10499,6 +10503,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.9:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
pretty-format@30.3.0:
dependencies:
'@jest/schemas': 30.0.5
@@ -11066,7 +11076,7 @@ snapshots:
tinyexec@1.0.4: {}
tinyglobby@0.2.15:
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
@@ -11288,9 +11298,9 @@ snapshots:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.8
postcss: 8.5.9
rollup: 4.60.1
tinyglobby: 0.2.15
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 25.5.2
fsevents: 2.3.3
+1 -1
View File
@@ -81,7 +81,7 @@ echo -e "${YELLOW}Waiting for MinIO to be healthy...${NC}"
MAX_RETRIES=30
RETRY_COUNT=0
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
if curl -sf http://localhost:8987/minio/health/live > /dev/null 2>&1; then
if curl -sf http://127.0.0.1:8987/minio/health/live > /dev/null 2>&1; then
echo -e "${GREEN}MinIO is ready!${NC}"
break
fi
+4
View File
@@ -27,6 +27,10 @@ done
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
export AWS_DEFAULT_REGION="auto"
# aws-cli v2.23+ sends integrity checksums by default; R2 rejects them
# with `Unauthorized` on ListObjectsV2. Disable.
export AWS_REQUEST_CHECKSUM_CALCULATION="WHEN_REQUIRED"
export AWS_RESPONSE_CHECKSUM_VALIDATION="WHEN_REQUIRED"
# Ensure endpoint URL has https:// prefix
R2_ENDPOINT="$R2_ENDPOINT_URL"
+408 -103
View File
@@ -14,7 +14,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"generic-array",
]
@@ -25,10 +25,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cipher 0.4.4",
"cpufeatures 0.2.17",
]
[[package]]
name = "aes"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8"
dependencies = [
"cipher 0.5.1",
"cpubits",
"cpufeatures 0.3.0",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
@@ -36,8 +47,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"aes 0.8.4",
"cipher 0.4.4",
"ctr",
"ghash",
"subtle",
@@ -158,7 +169,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]]
@@ -169,7 +180,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -339,9 +350,9 @@ dependencies = [
[[package]]
name = "async-signal"
version = "0.2.13"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
@@ -602,7 +613,7 @@ version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@@ -629,12 +640,21 @@ dependencies = [
]
[[package]]
name = "block-padding"
version = "0.3.3"
name = "block-buffer"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
dependencies = [
"generic-array",
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
dependencies = [
"hybrid-array",
]
[[package]]
@@ -762,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"
@@ -820,15 +846,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"
@@ -920,18 +937,18 @@ dependencies = [
[[package]]
name = "cbc"
version = "0.1.2"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
checksum = "98db6aeaef0eeef2c1e3ce9a27b739218825dae116076352ac3777076aa22225"
dependencies = [
"cipher",
"cipher 0.5.1",
]
[[package]]
name = "cc"
version = "1.2.59"
version = "1.2.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -996,7 +1013,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cipher 0.4.4",
"cpufeatures 0.2.17",
]
@@ -1019,7 +1036,7 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20 0.9.1",
"cipher",
"cipher 0.4.4",
"poly1305",
"zeroize",
]
@@ -1054,11 +1071,21 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"crypto-common 0.1.7",
"inout 0.1.4",
"zeroize",
]
[[package]]
name = "cipher"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
dependencies = [
"crypto-common 0.2.1",
"inout 0.2.2",
]
[[package]]
name = "clap"
version = "4.6.0"
@@ -1130,6 +1157,18 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "const-random"
version = "0.1.18"
@@ -1246,6 +1285,12 @@ dependencies = [
"libm",
]
[[package]]
name = "cpubits"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ef0c543070d296ea414df2dd7625d1b24866ce206709d8a4a424f28377f5861"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1339,6 +1384,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.29.6"
@@ -1395,7 +1449,7 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
"cipher 0.4.4",
]
[[package]]
@@ -1546,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"
@@ -1607,11 +1671,22 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
dependencies = [
"block-buffer 0.12.0",
"const-oid 0.10.2",
"crypto-common 0.2.1",
]
[[package]]
name = "directories"
version = "6.0.0"
@@ -1639,7 +1714,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1714,9 +1789,9 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.19.0"
version = "0.21.1"
dependencies = [
"aes",
"aes 0.9.0",
"aes-gcm",
"argon2",
"async-socks5",
@@ -1725,7 +1800,7 @@ dependencies = [
"base64 0.22.1",
"blake3",
"boringtun",
"bzip2 0.6.1",
"bzip2",
"cbc",
"chrono",
"chrono-tz",
@@ -1754,19 +1829,20 @@ dependencies = [
"objc2",
"objc2-app-kit",
"once_cell",
"pbkdf2",
"playwright",
"quick-xml 0.39.2",
"rand 0.10.0",
"rand 0.10.1",
"regex-lite",
"reqwest 0.13.2",
"resvg",
"ring",
"rusqlite",
"serde",
"serde_json",
"serde_yaml",
"serial_test",
"sha1",
"sha2 0.11.0",
"shadowsocks",
"smoltcp",
"sys-locale",
"sysinfo",
@@ -1798,7 +1874,7 @@ dependencies = [
"windows 0.62.2",
"winreg 0.56.0",
"wiremock",
"zip 8.5.0",
"zip 8.5.1",
]
[[package]]
@@ -1837,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"
@@ -1976,7 +2082,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2038,9 +2144,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.3.0"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fax"
@@ -2518,9 +2624,9 @@ dependencies = [
[[package]]
name = "gif"
version = "0.14.1"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
@@ -2802,13 +2908,22 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@@ -2884,6 +2999,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.9.0"
@@ -2961,7 +3085,7 @@ dependencies = [
"tower-layer",
"tower-service",
"tracing",
"windows-registry 0.6.1",
"windows-registry",
]
[[package]]
@@ -2976,7 +3100,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
"windows-core 0.61.2",
]
[[package]]
@@ -3197,10 +3321,19 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
[[package]]
name = "inout"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"block-padding",
"hybrid-array",
]
[[package]]
name = "interpolate_name"
version = "0.2.4"
@@ -3507,12 +3640,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.184"
@@ -3556,9 +3683,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.15"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
"bitflags 2.11.0",
"libc",
@@ -3755,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"
@@ -4411,7 +4548,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]]
@@ -4503,7 +4640,7 @@ version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"digest 0.10.7",
"hmac",
]
@@ -4724,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"
@@ -4741,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"
@@ -4756,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",
@@ -5140,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",
@@ -5547,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"
@@ -5668,7 +5848,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5695,9 +5875,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.10"
version = "0.103.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
dependencies = [
"ring",
"rustls-pki-types",
@@ -5830,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"
@@ -5900,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"
@@ -6137,7 +6338,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
@@ -6148,7 +6349,67 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"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]]
@@ -6199,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"
@@ -6283,7 +6550,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]]
@@ -6334,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"
@@ -6753,7 +7039,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"syn 2.0.117",
"tauri-utils",
"thiserror 2.0.18",
@@ -6796,9 +7082,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.7"
version = "2.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94deb2e2e4641514ac496db2cddcfc850d6fc9d51ea17b82292a0490bd20ba5b"
checksum = "3db49816aee496a9b200d55b55ab6ae73fd50847c79f2fabc7ee20871fa75c95"
dependencies = [
"dunce",
"plist",
@@ -6811,15 +7097,15 @@ dependencies = [
"thiserror 2.0.18",
"tracing",
"url",
"windows-registry 0.5.3",
"windows-registry",
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809"
dependencies = [
"log",
"raw-window-handle",
@@ -6835,13 +7121,15 @@ dependencies = [
[[package]]
name = "tauri-plugin-fs"
version = "2.4.5"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
@@ -6937,9 +7225,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.0"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c"
checksum = "a33a5b7d78f0dec4406b003ea87c40bf928d801b6fd9323a556172c91d8712c1"
dependencies = [
"serde",
"serde_json",
@@ -7060,7 +7348,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -7233,9 +7521,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.51.0"
version = "1.51.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
dependencies = [
"bytes",
"libc",
@@ -7291,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"
@@ -7523,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"
@@ -7641,7 +7957,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7751,7 +8067,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"subtle",
]
@@ -8235,7 +8551,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.59.0",
]
[[package]]
@@ -8415,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"
@@ -8764,7 +9069,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -8914,7 +9219,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"raw-window-handle",
"sha2",
"sha2 0.10.9",
"soup3",
"tao-macros",
"thiserror 2.0.18",
@@ -9185,9 +9490,9 @@ version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"aes",
"aes 0.8.4",
"arbitrary",
"bzip2 0.5.2",
"bzip2",
"constant_time_eq 0.3.1",
"crc32fast",
"crossbeam-utils",
@@ -9211,9 +9516,9 @@ dependencies = [
[[package]]
name = "zip"
version = "8.5.0"
version = "8.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2726508a48f38dceb22b35ecbbd2430efe34ff05c62bd3285f965d7911b33464"
checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59"
dependencies = [
"crc32fast",
"flate2",
+8 -7
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.19.0"
version = "0.21.1"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -76,15 +76,16 @@ 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"
aes-gcm = "0.10"
aes = "0.8"
cbc = "0.1"
pbkdf2 = "0.12"
sha1 = "0.10"
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"] }
+30 -42
View File
@@ -31,6 +31,7 @@ pub struct ApiProfile {
pub browser: String,
pub version: String,
pub proxy_id: Option<String>,
pub launch_hook: Option<String>,
pub process_id: Option<u32>,
pub last_launch: Option<u64>,
pub release_type: String,
@@ -59,6 +60,7 @@ pub struct CreateProfileRequest {
pub browser: String,
pub version: String,
pub proxy_id: Option<String>,
pub launch_hook: Option<String>,
pub release_type: Option<String>,
#[schema(value_type = Object)]
pub camoufox_config: Option<serde_json::Value>,
@@ -74,6 +76,7 @@ pub struct UpdateProfileRequest {
pub browser: Option<String>,
pub version: Option<String>,
pub proxy_id: Option<String>,
pub launch_hook: Option<String>,
pub release_type: Option<String>,
#[schema(value_type = Object)]
pub camoufox_config: Option<serde_json::Value>,
@@ -111,17 +114,13 @@ struct ApiProxyResponse {
name: String,
#[schema(value_type = Object)]
proxy_settings: ProxySettings,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
struct CreateProxyRequest {
name: String,
#[schema(value_type = Object)]
proxy_settings: Option<ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
proxy_settings: ProxySettings,
}
#[derive(Debug, Deserialize, ToSchema)]
@@ -129,8 +128,6 @@ struct UpdateProxyRequest {
name: Option<String>,
#[schema(value_type = Object)]
proxy_settings: Option<ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
@@ -486,6 +483,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
browser: profile.browser.clone(),
version: profile.version.clone(),
proxy_id: profile.proxy_id.clone(),
launch_hook: profile.launch_hook.clone(),
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type.clone(),
@@ -541,6 +539,7 @@ async fn get_profile(
browser: profile.browser.clone(),
version: profile.version.clone(),
proxy_id: profile.proxy_id.clone(),
launch_hook: profile.launch_hook.clone(),
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type.clone(),
@@ -612,6 +611,7 @@ async fn create_profile(
request.group_id.clone(),
false,
None,
request.launch_hook.clone(),
)
.await
{
@@ -641,6 +641,7 @@ async fn create_profile(
browser: profile.browser,
version: profile.version,
proxy_id: profile.proxy_id,
launch_hook: profile.launch_hook,
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type,
@@ -714,6 +715,21 @@ async fn update_profile(
}
}
if let Some(launch_hook) = request.launch_hook {
let normalized = if launch_hook.trim().is_empty() {
None
} else {
Some(launch_hook)
};
if profile_manager
.update_profile_launch_hook(&state.app_handle, &id, normalized)
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
}
}
if let Some(camoufox_config) = request.camoufox_config {
let config: Result<CamoufoxConfig, _> = serde_json::from_value(camoufox_config);
match config {
@@ -1035,8 +1051,6 @@ async fn get_proxies(
.map(|p| ApiProxyResponse {
id: p.id,
name: p.name,
dynamic_proxy_url: p.dynamic_proxy_url,
dynamic_proxy_format: p.dynamic_proxy_format,
proxy_settings: p.proxy_settings,
})
.collect(),
@@ -1070,8 +1084,6 @@ async fn get_proxy(
id: proxy.id,
name: proxy.name,
proxy_settings: proxy.proxy_settings,
dynamic_proxy_url: proxy.dynamic_proxy_url,
dynamic_proxy_format: proxy.dynamic_proxy_format,
}))
} else {
Err(StatusCode::NOT_FOUND)
@@ -1097,27 +1109,16 @@ async fn create_proxy(
State(state): State<ApiServerState>,
Json(request): Json<CreateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, StatusCode> {
let result = if let (Some(url), Some(format)) =
(&request.dynamic_proxy_url, &request.dynamic_proxy_format)
{
PROXY_MANAGER.create_dynamic_proxy(
&state.app_handle,
request.name.clone(),
url.clone(),
format.clone(),
)
} else if let Some(settings) = request.proxy_settings {
PROXY_MANAGER.create_stored_proxy(&state.app_handle, request.name.clone(), settings)
} else {
return Err(StatusCode::BAD_REQUEST);
};
let result = PROXY_MANAGER.create_stored_proxy(
&state.app_handle,
request.name.clone(),
request.proxy_settings,
);
match result {
Ok(proxy) => Ok(Json(ApiProxyResponse {
id: proxy.id,
name: proxy.name,
dynamic_proxy_url: proxy.dynamic_proxy_url,
dynamic_proxy_format: proxy.dynamic_proxy_format,
proxy_settings: proxy.proxy_settings,
})),
Err(_) => Err(StatusCode::BAD_REQUEST),
@@ -1148,26 +1149,13 @@ async fn update_proxy(
State(state): State<ApiServerState>,
Json(request): Json<UpdateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, StatusCode> {
let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(&id) || request.dynamic_proxy_url.is_some();
let result = if is_dynamic {
PROXY_MANAGER.update_dynamic_proxy(
&state.app_handle,
&id,
request.name,
request.dynamic_proxy_url,
request.dynamic_proxy_format,
)
} else {
PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings)
};
let result =
PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings);
match result {
Ok(proxy) => Ok(Json(ApiProxyResponse {
id: proxy.id,
name: proxy.name,
dynamic_proxy_url: proxy.dynamic_proxy_url,
dynamic_proxy_format: proxy.dynamic_proxy_format,
proxy_settings: proxy.proxy_settings,
})),
Err(_) => Err(StatusCode::NOT_FOUND),
+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);
}
+1
View File
@@ -683,6 +683,7 @@ mod tests {
process_id: None,
proxy_id: None,
vpn_id: None,
launch_hook: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
+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));
}
};
+2 -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>,
@@ -1199,6 +1199,7 @@ mod tests {
version: "1.0.0".to_string(),
proxy_id: None,
vpn_id: None,
launch_hook: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
+37 -21
View File
@@ -9,7 +9,7 @@ use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
use serde::Serialize;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sysinfo::System;
pub struct BrowserRunner {
pub profile_manager: &'static ProfileManager,
@@ -60,8 +60,6 @@ impl BrowserRunner {
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
/// then resolve the proxy settings with profile-specific sid for sticky sessions.
/// Resolve proxy settings for a profile, returning an error for dynamic proxy failures.
/// Returns Ok(None) when no proxy is configured, Ok(Some) on success, Err on dynamic fetch failure.
async fn resolve_proxy_with_refresh(
&self,
proxy_id: Option<&String>,
@@ -72,13 +70,6 @@ impl BrowserRunner {
None => return Ok(None),
};
// Handle dynamic proxies: fetch from URL at launch time
if PROXY_MANAGER.is_dynamic_proxy(proxy_id) {
log::info!("Fetching dynamic proxy settings for proxy {proxy_id}");
let settings = PROXY_MANAGER.resolve_dynamic_proxy(proxy_id).await?;
return Ok(Some(settings));
}
if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
log::info!("Refreshing cloud proxy credentials before launch for proxy {proxy_id}");
CLOUD_AUTH.sync_cloud_proxy().await;
@@ -92,6 +83,38 @@ impl BrowserRunner {
Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))
}
async fn resolve_launch_hook_proxy(
&self,
profile: &BrowserProfile,
) -> Result<Option<ProxySettings>, String> {
let Some(url) = profile.launch_hook.as_deref() else {
return Ok(None);
};
log::info!(
"Calling launch hook for profile {} (ID: {})",
profile.name,
profile.id
);
PROXY_MANAGER
.fetch_proxy_from_url(url, Duration::from_millis(500))
.await
}
async fn resolve_launch_proxy(
&self,
profile: &BrowserProfile,
) -> Result<Option<ProxySettings>, String> {
if let Some(proxy_settings) = self.resolve_launch_hook_proxy(profile).await? {
return Ok(Some(proxy_settings));
}
self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await
}
/// Get the executable path for a browser profile
/// This is a common helper to eliminate code duplication across the codebase
pub fn get_browser_executable_path(
@@ -147,9 +170,8 @@ impl BrowserRunner {
});
// Always start a local proxy for Camoufox (for traffic monitoring and geoip support)
// Refresh cloud proxy credentials if needed before resolving
let mut upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.resolve_launch_proxy(profile)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
@@ -408,9 +430,8 @@ impl BrowserRunner {
});
// Always start a local proxy for Wayfern (for traffic monitoring and geoip support)
// Refresh cloud proxy credentials if needed before resolving
let mut upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.resolve_launch_proxy(profile)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
@@ -763,10 +784,8 @@ impl BrowserRunner {
headless: bool,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Always start a local proxy for API launches
// Determine upstream proxy if configured; otherwise use DIRECT
// Refresh cloud proxy credentials before resolving
let upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.resolve_launch_proxy(profile)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
@@ -2273,10 +2292,7 @@ pub async fn launch_browser_profile(
// Determine upstream proxy if configured; otherwise use DIRECT (no upstream)
// Refresh cloud proxy credentials and inject profile-specific sid
let mut upstream_proxy = BrowserRunner::instance()
.resolve_proxy_with_refresh(
profile_for_launch.proxy_id.as_ref(),
Some(&profile_for_launch.id.to_string()),
)
.resolve_launch_proxy(&profile_for_launch)
.await?;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
+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,
File diff suppressed because it is too large Load Diff
+1
View File
@@ -260,6 +260,7 @@ mod tests {
version: "1.0".to_string(),
proxy_id: None,
vpn_id: None,
launch_hook: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
+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)
}
+128 -57
View File
@@ -67,8 +67,9 @@ use browser_runner::{
use profile::manager::{
check_browser_status, clone_profile, create_browser_profile_new, delete_profile,
list_browser_profiles, rename_profile, update_camoufox_config, update_profile_dns_blocklist,
update_profile_note, update_profile_proxy, update_profile_proxy_bypass_rules,
update_profile_tags, update_profile_vpn, update_wayfern_config,
update_profile_launch_hook, update_profile_note, update_profile_proxy,
update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn,
update_wayfern_config,
};
use browser_version_manager::{
@@ -212,19 +213,13 @@ async fn create_stored_proxy(
app_handle: tauri::AppHandle,
name: String,
proxy_settings: Option<crate::browser::ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
) -> Result<crate::proxy_manager::StoredProxy, String> {
if let (Some(url), Some(format)) = (&dynamic_proxy_url, &dynamic_proxy_format) {
crate::proxy_manager::PROXY_MANAGER
.create_dynamic_proxy(&app_handle, name, url.clone(), format.clone())
.map_err(|e| format!("Failed to create dynamic proxy: {e}"))
} else if let Some(settings) = proxy_settings {
if let Some(settings) = proxy_settings {
crate::proxy_manager::PROXY_MANAGER
.create_stored_proxy(&app_handle, name, settings)
.map_err(|e| format!("Failed to create stored proxy: {e}"))
} else {
Err("Either proxy_settings or dynamic proxy URL and format are required".to_string())
Err("proxy_settings is required".to_string())
}
}
@@ -239,26 +234,10 @@ async fn update_stored_proxy(
proxy_id: String,
name: Option<String>,
proxy_settings: Option<crate::browser::ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
) -> Result<crate::proxy_manager::StoredProxy, String> {
// Check if this is a dynamic proxy update
let is_dynamic = crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id);
if is_dynamic || dynamic_proxy_url.is_some() {
crate::proxy_manager::PROXY_MANAGER
.update_dynamic_proxy(
&app_handle,
&proxy_id,
name,
dynamic_proxy_url,
dynamic_proxy_format,
)
.map_err(|e| format!("Failed to update dynamic proxy: {e}"))
} else {
crate::proxy_manager::PROXY_MANAGER
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
.map_err(|e| format!("Failed to update stored proxy: {e}"))
}
crate::proxy_manager::PROXY_MANAGER
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
.map_err(|e| format!("Failed to update stored proxy: {e}"))
}
#[tauri::command]
@@ -273,13 +252,8 @@ async fn check_proxy_validity(
proxy_id: String,
proxy_settings: Option<crate::browser::ProxySettings>,
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
// For dynamic proxies, fetch settings first
let settings = if let Some(s) = proxy_settings {
s
} else if crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id) {
crate::proxy_manager::PROXY_MANAGER
.resolve_dynamic_proxy(&proxy_id)
.await?
} else {
crate::proxy_manager::PROXY_MANAGER
.get_proxy_settings_by_id(&proxy_id)
@@ -290,24 +264,6 @@ async fn check_proxy_validity(
.await
}
#[tauri::command]
async fn fetch_dynamic_proxy(
url: String,
format: String,
) -> Result<crate::browser::ProxySettings, String> {
let settings = crate::proxy_manager::PROXY_MANAGER
.fetch_dynamic_proxy(&url, &format)
.await?;
// Validate the proxy actually works by connecting through it
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]
fn get_cached_proxy_check(proxy_id: String) -> Option<crate::proxy_manager::ProxyCheckResult> {
crate::proxy_manager::PROXY_MANAGER.get_cached_proxy_check(&proxy_id)
@@ -1189,6 +1145,7 @@ async fn generate_sample_fingerprint(
process_id: None,
proxy_id: None,
vpn_id: None,
launch_hook: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
@@ -1459,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.
{
@@ -1580,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;
@@ -1654,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) => {
@@ -1675,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
@@ -1889,6 +1960,7 @@ pub fn run() {
update_profile_vpn,
update_profile_tags,
update_profile_note,
update_profile_launch_hook,
update_profile_proxy_bypass_rules,
update_profile_dns_blocklist,
check_browser_status,
@@ -1929,7 +2001,6 @@ pub fn run() {
update_stored_proxy,
delete_stored_proxy,
check_proxy_validity,
fetch_dynamic_proxy,
get_cached_proxy_check,
export_proxies,
import_proxies_json,
+87 -109
View File
@@ -508,6 +508,10 @@ impl McpServer {
"type": "string",
"description": "Optional proxy UUID to assign"
},
"launch_hook": {
"type": "string",
"description": "Optional HTTP(S) URL to call before launch for transient proxy overrides"
},
"group_id": {
"type": "string",
"description": "Optional group UUID to assign"
@@ -539,6 +543,10 @@ impl McpServer {
"type": "string",
"description": "Proxy UUID to assign (empty string to remove)"
},
"launch_hook": {
"type": "string",
"description": "Launch hook URL to assign (empty string to remove)"
},
"group_id": {
"type": "string",
"description": "Group UUID to assign (empty string to remove)"
@@ -713,7 +721,7 @@ impl McpServer {
},
McpTool {
name: "create_proxy".to_string(),
description: "Create a new proxy configuration. For regular proxies, provide proxy_type/host/port. For dynamic proxies, provide dynamic_proxy_url and dynamic_proxy_format instead.".to_string(),
description: "Create a new proxy configuration.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -741,18 +749,9 @@ impl McpServer {
"password": {
"type": "string",
"description": "Optional password for authentication (for regular proxies)"
},
"dynamic_proxy_url": {
"type": "string",
"description": "URL to fetch proxy settings from (for dynamic proxies)"
},
"dynamic_proxy_format": {
"type": "string",
"enum": ["json", "text"],
"description": "Format of the dynamic proxy response: 'json' for JSON object or 'text' for text like host:port:user:pass (for dynamic proxies)"
}
},
"required": ["name"]
"required": ["name", "proxy_type", "host", "port"]
}),
},
McpTool {
@@ -789,15 +788,6 @@ impl McpServer {
"password": {
"type": "string",
"description": "Optional password for authentication (for regular proxies)"
},
"dynamic_proxy_url": {
"type": "string",
"description": "URL to fetch proxy settings from (for dynamic proxies)"
},
"dynamic_proxy_format": {
"type": "string",
"enum": ["json", "text"],
"description": "Format of the dynamic proxy response (for dynamic proxies)"
}
},
"required": ["proxy_id"]
@@ -1809,6 +1799,10 @@ impl McpServer {
.get("proxy_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let launch_hook = arguments
.get("launch_hook")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let group_id = arguments
.get("group_id")
.and_then(|v| v.as_str())
@@ -1838,8 +1832,19 @@ impl McpServer {
let mut profile = ProfileManager::instance()
.create_profile_with_group(
app_handle, name, browser, version, "stable", proxy_id, None, None, None, group_id, false,
app_handle,
name,
browser,
version,
"stable",
proxy_id,
None,
None,
None,
group_id,
false,
None,
launch_hook,
)
.await
.map_err(|e| McpError {
@@ -1907,6 +1912,19 @@ impl McpServer {
})?;
}
if let Some(launch_hook) = arguments.get("launch_hook").and_then(|v| v.as_str()) {
let normalized = if launch_hook.is_empty() {
None
} else {
Some(launch_hook.to_string())
};
pm.update_profile_launch_hook(app_handle, profile_id, normalized)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update launch hook: {e}"),
})?;
}
if let Some(group_id) = arguments.get("group_id").and_then(|v| v.as_str()) {
let gid = if group_id.is_empty() {
None
@@ -2361,74 +2379,54 @@ impl McpServer {
message: "MCP server not properly initialized".to_string(),
})?;
// Check if this is a dynamic proxy creation
let dynamic_url = arguments.get("dynamic_proxy_url").and_then(|v| v.as_str());
let dynamic_format = arguments
.get("dynamic_proxy_format")
.and_then(|v| v.as_str());
let proxy_type = arguments
.get("proxy_type")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing proxy_type".to_string(),
})?;
let proxy = if let (Some(url), Some(format)) = (dynamic_url, dynamic_format) {
PROXY_MANAGER
.create_dynamic_proxy(
app_handle,
name.to_string(),
url.to_string(),
format.to_string(),
)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to create dynamic proxy: {e}"),
})?
} else {
let proxy_type = arguments
.get("proxy_type")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing proxy_type (required for regular proxies)".to_string(),
})?;
let host = arguments
.get("host")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing host".to_string(),
})?;
let host = arguments
.get("host")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing host (required for regular proxies)".to_string(),
})?;
let port = arguments
.get("port")
.and_then(|v| v.as_u64())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing port".to_string(),
})? as u16;
let port = arguments
.get("port")
.and_then(|v| v.as_u64())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing port (required for regular proxies)".to_string(),
})? as u16;
let username = arguments
.get("username")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let password = arguments
.get("password")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let username = arguments
.get("username")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let password = arguments
.get("password")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let proxy_settings = ProxySettings {
proxy_type: proxy_type.to_string(),
host: host.to_string(),
port,
username,
password,
};
PROXY_MANAGER
.create_stored_proxy(app_handle, name.to_string(), proxy_settings)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to create proxy: {e}"),
})?
let proxy_settings = ProxySettings {
proxy_type: proxy_type.to_string(),
host: host.to_string(),
port,
username,
password,
};
let proxy = PROXY_MANAGER
.create_stored_proxy(app_handle, name.to_string(), proxy_settings)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to create proxy: {e}"),
})?;
Ok(serde_json::json!({
"content": [{
"type": "text",
@@ -2517,32 +2515,12 @@ impl McpServer {
message: "MCP server not properly initialized".to_string(),
})?;
// Check for dynamic proxy fields
let dynamic_url = arguments
.get("dynamic_proxy_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let dynamic_format = arguments
.get("dynamic_proxy_format")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(proxy_id) || dynamic_url.is_some();
let proxy = if is_dynamic {
PROXY_MANAGER
.update_dynamic_proxy(app_handle, proxy_id, name, dynamic_url, dynamic_format)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update dynamic proxy: {e}"),
})?
} else {
PROXY_MANAGER
.update_stored_proxy(app_handle, proxy_id, name, proxy_settings)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update proxy: {e}"),
})?
};
let proxy = PROXY_MANAGER
.update_stored_proxy(app_handle, proxy_id, name, proxy_settings)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update proxy: {e}"),
})?;
Ok(serde_json::json!({
"content": [{
+23 -177
View File
@@ -89,96 +89,17 @@ pub mod macos {
}
}
// Fallback: Use AppleScript
let escaped_url = url
.replace("\"", "\\\"")
.replace("\\", "\\\\")
.replace("'", "\\'");
let script = format!(
r#"
try
tell application "System Events"
-- Find the exact process by PID
set targetProcess to (first application process whose unix id is {pid})
-- Verify the process exists
if not (exists targetProcess) then
error "No process found with PID {pid}"
end if
-- Get the process name for verification
set processName to name of targetProcess
-- Bring the process to the front first
set frontmost of targetProcess to true
delay 1.0
-- Check if the process has any visible windows
set windowList to windows of targetProcess
set hasVisibleWindow to false
repeat with w in windowList
if visible of w is true then
set hasVisibleWindow to true
exit repeat
end if
end repeat
if not hasVisibleWindow then
-- No visible windows, create a new one
tell targetProcess
keystroke "n" using command down
delay 2.0
end tell
end if
-- Ensure the process is frontmost again
set frontmost of targetProcess to true
delay 0.5
-- Focus on the address bar and open URL
tell targetProcess
-- Open a new tab
keystroke "t" using command down
delay 1.5
-- Focus address bar (Cmd+L)
keystroke "l" using command down
delay 0.5
-- Type the URL
keystroke "{escaped_url}"
delay 0.5
-- Press Enter to navigate
keystroke return
end tell
return "Successfully opened URL in " & processName & " (PID: {pid})"
end tell
on error errMsg number errNum
return "AppleScript failed: " & errMsg & " (Error " & errNum & ")"
end try
"#
);
log::info!("Executing AppleScript fallback for Firefox-based browser (PID: {pid})...");
let output = Command::new("osascript").args(["-e", &script]).output()?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
log::info!("AppleScript failed: {error_msg}");
return Err(
format!(
"Both Firefox remote command and AppleScript failed. AppleScript error: {error_msg}"
)
.into(),
);
} else {
log::info!("AppleScript succeeded");
}
Ok(())
// The Firefox `-new-tab` remote command failed. We intentionally do NOT
// fall back to an AppleScript `System Events` keystroke path: that would
// send Apple Events to another application and trigger the macOS TCC
// "<Donut> wants control of <Browser>" / "prevented from modifying other
// apps" prompts. Donut must never touch other apps on the user's Mac.
Err(
format!(
"Firefox remote command failed for PID {pid}; cannot open URL in existing window without touching other apps"
)
.into(),
)
}
pub async fn kill_browser_process_impl(
@@ -378,93 +299,18 @@ end try
}
}
// Fallback to AppleScript
let escaped_url = url
.replace("\"", "\\\"")
.replace("\\", "\\\\")
.replace("'", "\\'");
let script = format!(
r#"
try
tell application "System Events"
-- Find the exact process by PID
set targetProcess to (first application process whose unix id is {pid})
-- Verify the process exists
if not (exists targetProcess) then
error "No process found with PID {pid}"
end if
-- Get the process name for verification
set processName to name of targetProcess
-- Bring the process to the front first
set frontmost of targetProcess to true
delay 1.0
-- Check if the process has any visible windows
set windowList to windows of targetProcess
set hasVisibleWindow to false
repeat with w in windowList
if visible of w is true then
set hasVisibleWindow to true
exit repeat
end if
end repeat
if not hasVisibleWindow then
-- No visible windows, create a new one
tell targetProcess
keystroke "n" using command down
delay 2.0
end tell
end if
-- Ensure the process is frontmost again
set frontmost of targetProcess to true
delay 0.5
-- Focus on the address bar and open URL
tell targetProcess
-- Open a new tab
keystroke "t" using command down
delay 1.5
-- Focus address bar (Cmd+L)
keystroke "l" using command down
delay 0.5
-- Type the URL
keystroke "{escaped_url}"
delay 0.5
-- Press Enter to navigate
keystroke return
end tell
return "Successfully opened URL in " & processName & " (PID: {pid})"
end tell
on error errMsg number errNum
return "AppleScript failed: " & errMsg & " (Error " & errNum & ")"
end try
"#
);
log::info!("Executing AppleScript for Chromium-based browser (PID: {pid})...");
let output = Command::new("osascript").args(["-e", &script]).output()?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
log::info!("AppleScript failed: {error_msg}");
return Err(
format!("Failed to open URL in existing Chromium-based browser: {error_msg}").into(),
);
} else {
log::info!("AppleScript succeeded");
}
Ok(())
// The Chromium `--user-data-dir=<path> <url>` remote command failed.
// We intentionally do NOT fall back to an AppleScript `System Events`
// keystroke path: that would send Apple Events to another application
// and trigger the macOS TCC "<Donut> wants control of <Browser>" /
// "prevented from modifying other apps" prompts. Donut must never touch
// other apps on the user's Mac.
Err(
format!(
"Chromium remote command failed for PID {pid}; cannot open URL in existing window without touching other apps"
)
.into(),
)
}
}
+102
View File
@@ -10,6 +10,7 @@ use crate::wayfern_manager::WayfernConfig;
use std::fs::{self, create_dir_all};
use std::path::{Path, PathBuf};
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
use url::Url;
pub struct ProfileManager {
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
@@ -36,6 +37,25 @@ impl ProfileManager {
crate::app_dirs::binaries_dir()
}
fn normalize_launch_hook(
launch_hook: Option<String>,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let Some(raw) = launch_hook else {
return Ok(None);
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
let parsed = Url::parse(trimmed).map_err(|e| format!("Invalid launch hook URL: {e}"))?;
match parsed.scheme() {
"http" | "https" => Ok(Some(parsed.to_string())),
_ => Err("Launch hook URL must use http or https".into()),
}
}
#[allow(clippy::too_many_arguments)]
pub async fn create_profile_with_group(
&self,
@@ -51,11 +71,14 @@ impl ProfileManager {
group_id: Option<String>,
ephemeral: bool,
dns_blocklist: Option<String>,
launch_hook: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
if proxy_id.is_some() && vpn_id.is_some() {
return Err("Cannot set both proxy_id and vpn_id".into());
}
let launch_hook = Self::normalize_launch_hook(launch_hook)?;
// Sync cloud proxy credentials if the profile uses a cloud or cloud-derived proxy
if let Some(ref pid) = proxy_id {
if PROXY_MANAGER.is_cloud_or_derived(pid) || pid == crate::proxy_manager::CLOUD_PROXY_ID {
@@ -142,6 +165,7 @@ impl ProfileManager {
version: version.to_string(),
proxy_id: proxy_id.clone(),
vpn_id: None,
launch_hook: launch_hook.clone(),
process_id: None,
last_launch: None,
release_type: release_type.to_string(),
@@ -242,6 +266,7 @@ impl ProfileManager {
version: version.to_string(),
proxy_id: proxy_id.clone(),
vpn_id: None,
launch_hook: launch_hook.clone(),
process_id: None,
last_launch: None,
release_type: release_type.to_string(),
@@ -296,6 +321,7 @@ impl ProfileManager {
version: version.to_string(),
proxy_id: proxy_id.clone(),
vpn_id: vpn_id.clone(),
launch_hook,
process_id: None,
last_launch: None,
release_type: release_type.to_string(),
@@ -739,6 +765,35 @@ impl ProfileManager {
Ok(profile)
}
pub fn update_profile_launch_hook(
&self,
_app_handle: &tauri::AppHandle,
profile_id: &str,
launch_hook: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profiles = self.list_profiles()?;
let mut profile = profiles
.into_iter()
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.launch_hook = Self::normalize_launch_hook(launch_hook)?;
self.save_profile(&profile)?;
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
}
pub fn update_profile_proxy_bypass_rules(
&self,
_app_handle: &tauri::AppHandle,
@@ -913,6 +968,7 @@ impl ProfileManager {
version: source.version,
proxy_id: source.proxy_id,
vpn_id: source.vpn_id,
launch_hook: source.launch_hook,
process_id: None,
last_launch: None,
release_type: source.release_type,
@@ -1970,6 +2026,36 @@ mod tests {
"PAC URL should percent-encode spaces: {pac_line}"
);
}
#[test]
fn test_normalize_launch_hook_accepts_http_and_https() {
let http =
ProfileManager::normalize_launch_hook(Some(" http://localhost:3000/hook ".to_string()))
.unwrap();
let https = ProfileManager::normalize_launch_hook(Some(
"https://example.com/hooks/profile-launch".to_string(),
))
.unwrap();
assert_eq!(http.as_deref(), Some("http://localhost:3000/hook"));
assert_eq!(
https.as_deref(),
Some("https://example.com/hooks/profile-launch")
);
}
#[test]
fn test_normalize_launch_hook_clears_empty_values() {
let result = ProfileManager::normalize_launch_hook(Some(" ".to_string())).unwrap();
assert!(result.is_none());
}
#[test]
fn test_normalize_launch_hook_rejects_invalid_scheme() {
let err = ProfileManager::normalize_launch_hook(Some("ftp://example.com/hook".to_string()))
.unwrap_err();
assert!(err.to_string().contains("http or https"));
}
}
#[allow(clippy::too_many_arguments)]
@@ -1987,6 +2073,7 @@ pub async fn create_browser_profile_with_group(
group_id: Option<String>,
ephemeral: bool,
dns_blocklist: Option<String>,
launch_hook: Option<String>,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
@@ -2003,6 +2090,7 @@ pub async fn create_browser_profile_with_group(
group_id,
ephemeral,
dns_blocklist,
launch_hook,
)
.await
.map_err(|e| format!("Failed to create profile: {e}"))
@@ -2066,6 +2154,18 @@ pub fn update_profile_note(
.map_err(|e| format!("Failed to update profile note: {e}"))
}
#[tauri::command]
pub fn update_profile_launch_hook(
app_handle: tauri::AppHandle,
profile_id: String,
launch_hook: Option<String>,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
.update_profile_launch_hook(&app_handle, &profile_id, launch_hook)
.map_err(|e| format!("Failed to update profile launch hook: {e}"))
}
#[tauri::command]
pub fn update_profile_proxy_bypass_rules(
app_handle: tauri::AppHandle,
@@ -2128,6 +2228,7 @@ pub async fn create_browser_profile_new(
group_id: Option<String>,
ephemeral: Option<bool>,
dns_blocklist: Option<String>,
launch_hook: Option<String>,
) -> Result<BrowserProfile, String> {
let fingerprint_os = camoufox_config
.as_ref()
@@ -2156,6 +2257,7 @@ pub async fn create_browser_profile_new(
group_id,
ephemeral.unwrap_or(false),
dns_blocklist,
launch_hook,
)
.await
}
+2
View File
@@ -32,6 +32,8 @@ pub struct BrowserProfile {
#[serde(default)]
pub vpn_id: Option<String>, // Reference to stored VPN config
#[serde(default)]
pub launch_hook: Option<String>,
#[serde(default)]
pub process_id: Option<u32>,
#[serde(default)]
pub last_launch: Option<u64>,
+3
View File
@@ -565,6 +565,7 @@ impl ProfileImporter {
version: version.clone(),
proxy_id: proxy_id.clone(),
vpn_id: None,
launch_hook: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
@@ -644,6 +645,7 @@ impl ProfileImporter {
version: version.clone(),
proxy_id: proxy_id.clone(),
vpn_id: None,
launch_hook: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
@@ -694,6 +696,7 @@ impl ProfileImporter {
version,
proxy_id,
vpn_id: None,
launch_hook: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
+249 -217
View File
@@ -145,10 +145,6 @@ impl StoredProxy {
}
}
pub fn is_dynamic(&self) -> bool {
self.dynamic_proxy_url.is_some()
}
/// Migrate legacy geo_state to geo_region
pub fn migrate_geo_fields(&mut self) {
if self.geo_region.is_none() && self.geo_state.is_some() {
@@ -1009,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
}
@@ -1066,20 +1074,13 @@ impl ProxyManager {
self.load_proxy_check_cache(proxy_id)
}
// Check if a stored proxy is dynamic
pub fn is_dynamic_proxy(&self, proxy_id: &str) -> bool {
let stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.get(proxy_id).is_some_and(|p| p.is_dynamic())
}
// Fetch proxy settings from a dynamic proxy URL
pub async fn fetch_dynamic_proxy(
pub async fn fetch_proxy_from_url(
&self,
url: &str,
format: &str,
) -> Result<ProxySettings, String> {
timeout: std::time::Duration,
) -> Result<Option<ProxySettings>, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.timeout(timeout)
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
@@ -1087,33 +1088,39 @@ impl ProxyManager {
.get(url)
.send()
.await
.map_err(|e| format!("Failed to fetch dynamic proxy: {e}"))?;
.map_err(|e| format!("Failed to fetch launch hook: {e}"))?;
if response.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(None);
}
if !response.status().is_success() {
return Err(format!(
"Dynamic proxy URL returned status {}",
response.status()
));
return Err(format!("Launch hook returned status {}", response.status()));
}
let body = response
.text()
.await
.map_err(|e| format!("Failed to read dynamic proxy response: {e}"))?;
.map_err(|e| format!("Failed to read launch hook response: {e}"))?;
let body = body.trim();
if body.is_empty() {
return Err("Dynamic proxy URL returned empty response".to_string());
return Err("Launch hook returned empty response".to_string());
}
match format {
"json" => Self::parse_dynamic_proxy_json(body),
"text" => Self::parse_dynamic_proxy_text(body),
_ => Err(format!("Unsupported dynamic proxy format: {format}")),
if let Ok(settings) = Self::parse_dynamic_proxy_json(body) {
return Ok(Some(settings));
}
match Self::parse_dynamic_proxy_text(body) {
Ok(settings) => Ok(Some(settings)),
Err(text_error) => Err(format!(
"Failed to parse launch hook response: {text_error}"
)),
}
}
// Parse JSON format: { "ip"/"host": "...", "port": ..., "username": "...", "password": "..." }
// Parse JSON proxy payload: { "ip"/"host": "...", "port": ..., "username": "...", "password": "..." }
fn parse_dynamic_proxy_json(body: &str) -> Result<ProxySettings, String> {
let json: serde_json::Value =
serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))?;
@@ -1179,7 +1186,7 @@ impl ProxyManager {
})
}
// Parse text format using the same logic as proxy import
// Parse plain text proxy payload using the same logic as proxy import
fn parse_dynamic_proxy_text(body: &str) -> Result<ProxySettings, String> {
let line = body
.lines()
@@ -1210,136 +1217,6 @@ impl ProxyManager {
}
}
// Resolve dynamic proxy: fetch from URL and return settings
pub async fn resolve_dynamic_proxy(&self, proxy_id: &str) -> Result<ProxySettings, String> {
let (url, format) = {
let stored_proxies = self.stored_proxies.lock().unwrap();
let proxy = stored_proxies
.get(proxy_id)
.ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?;
match (&proxy.dynamic_proxy_url, &proxy.dynamic_proxy_format) {
(Some(url), Some(format)) => (url.clone(), format.clone()),
_ => return Err("Proxy is not a dynamic proxy".to_string()),
}
};
self.fetch_dynamic_proxy(&url, &format).await
}
// Create a dynamic stored proxy
pub fn create_dynamic_proxy(
&self,
_app_handle: &tauri::AppHandle,
name: String,
url: String,
format: String,
) -> Result<StoredProxy, String> {
{
let stored_proxies = self.stored_proxies.lock().unwrap();
if stored_proxies.values().any(|p| p.name == name) {
return Err(format!("Proxy with name '{name}' already exists"));
}
}
let placeholder_settings = ProxySettings {
proxy_type: "http".to_string(),
host: "dynamic".to_string(),
port: 0,
username: None,
password: None,
};
let mut stored_proxy = StoredProxy::new(name, placeholder_settings);
stored_proxy.dynamic_proxy_url = Some(url);
stored_proxy.dynamic_proxy_format = Some(format);
{
let mut stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.insert(stored_proxy.id.clone(), stored_proxy.clone());
}
if let Err(e) = self.save_proxy(&stored_proxy) {
log::warn!("Failed to save proxy: {e}");
}
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
}
if stored_proxy.sync_enabled {
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = stored_proxy.id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_proxy_sync(id).await;
});
}
}
Ok(stored_proxy)
}
// Update a dynamic proxy's URL and format
pub fn update_dynamic_proxy(
&self,
_app_handle: &tauri::AppHandle,
proxy_id: &str,
name: Option<String>,
url: Option<String>,
format: Option<String>,
) -> Result<StoredProxy, String> {
{
let stored_proxies = self.stored_proxies.lock().unwrap();
if !stored_proxies.contains_key(proxy_id) {
return Err(format!("Proxy with ID '{proxy_id}' not found"));
}
if let Some(ref new_name) = name {
if stored_proxies
.values()
.any(|p| p.id != proxy_id && p.name == *new_name)
{
return Err(format!("Proxy with name '{new_name}' already exists"));
}
}
}
let updated_proxy = {
let mut stored_proxies = self.stored_proxies.lock().unwrap();
let stored_proxy = stored_proxies.get_mut(proxy_id).unwrap();
if let Some(new_name) = name {
stored_proxy.update_name(new_name);
}
if let Some(new_url) = url {
stored_proxy.dynamic_proxy_url = Some(new_url);
}
if let Some(new_format) = format {
stored_proxy.dynamic_proxy_format = Some(new_format);
}
stored_proxy.clone()
};
if let Err(e) = self.save_proxy(&updated_proxy) {
log::warn!("Failed to save proxy: {e}");
}
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
}
if updated_proxy.sync_enabled {
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = updated_proxy.id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_proxy_sync(id).await;
});
}
}
Ok(updated_proxy)
}
// Export all proxies as JSON
pub fn export_proxies_json(&self) -> Result<String, String> {
let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -1491,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;
};
@@ -2131,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;
@@ -2239,6 +2241,8 @@ mod tests {
use hyper::Response;
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
// Helper function to build donut-proxy binary for testing
async fn ensure_donut_proxy_binary() -> Result<PathBuf, Box<dyn std::error::Error>> {
@@ -3668,74 +3672,102 @@ mod tests {
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());
#[tokio::test]
async fn test_fetch_proxy_from_url_parses_json_response() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{"host":"proxy.example.com","port":3128,"type":"socks5","username":"user","password":"pass"}"#,
),
)
.mount(&server)
.await;
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 result = pm
.fetch_proxy_from_url(
&format!("{}/hook", server.uri()),
Duration::from_millis(500),
)
.await
.unwrap()
.unwrap();
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"));
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert_eq!(result.proxy_type, "socks5");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[tokio::test]
async fn test_resolve_dynamic_proxy_not_dynamic() {
async fn test_fetch_proxy_from_url_parses_text_response() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(ResponseTemplate::new(200).set_body_string("socks5://user:pass@1.2.3.4:1080"))
.mount(&server)
.await;
let pm = ProxyManager::new();
let result = pm
.fetch_proxy_from_url(
&format!("{}/hook", server.uri()),
Duration::from_millis(500),
)
.await
.unwrap()
.unwrap();
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"));
assert_eq!(result.host, "1.2.3.4");
assert_eq!(result.port, 1080);
assert_eq!(result.proxy_type, "socks5");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[tokio::test]
async fn test_resolve_dynamic_proxy_not_found() {
let pm = ProxyManager::new();
async fn test_fetch_proxy_from_url_returns_none_for_no_content() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let err = pm.resolve_dynamic_proxy("nonexistent").await.unwrap_err();
assert!(err.contains("not found"));
let pm = ProxyManager::new();
let result = pm
.fetch_proxy_from_url(
&format!("{}/hook", server.uri()),
Duration::from_millis(500),
)
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_fetch_proxy_from_url_respects_timeout() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(200))
.set_body_string(r#"{"host":"1.2.3.4","port":8080}"#),
)
.mount(&server)
.await;
let pm = ProxyManager::new();
let err = pm
.fetch_proxy_from_url(&format!("{}/hook", server.uri()), Duration::from_millis(50))
.await
.unwrap_err();
assert!(err.contains("Failed to fetch launch hook"));
}
}
+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!(
+8 -15
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;
}
}
@@ -716,29 +716,22 @@ impl SyncScheduler {
match entity_type.as_str() {
"profile" => {
let profile_manager = ProfileManager::instance();
let profile_to_delete = {
let has_profile = {
if let Ok(profiles) = profile_manager.list_profiles() {
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok();
profile_uuid.and_then(|uuid| profiles.into_iter().find(|p| p.id == uuid))
profile_uuid.is_some_and(|uuid| profiles.iter().any(|p| p.id == uuid))
} else {
None
false
}
};
if let Some(mut profile) = profile_to_delete {
if has_profile {
log::info!(
"Profile {} was deleted remotely, disabling sync locally",
"Profile {} was deleted remotely, deleting locally",
entity_id
);
profile.sync_mode = crate::profile::types::SyncMode::Disabled;
if let Err(e) = profile_manager.save_profile(&profile) {
log::warn!("Failed to disable sync for profile {}: {}", entity_id, e);
} else {
log::info!(
"Profile {} sync disabled due to remote tombstone (local copy kept)",
entity_id
);
let _ = events::emit("profiles-changed", ());
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e);
}
}
}
+6 -13
View File
@@ -303,6 +303,11 @@ impl SynchronizerManager {
}
/// Bring the leader browser window to front.
///
/// On macOS this is a no-op on purpose: the only way to raise another
/// app's window from Rust is via `osascript` / Apple Events, which
/// triggers the TCC "prevented from modifying other apps" prompt. Donut
/// must never touch other apps on the user's Mac.
async fn focus_leader_window(leader: &BrowserProfile) {
let profile = match Self::get_profile(&leader.id.to_string()) {
Ok(p) => p,
@@ -312,18 +317,6 @@ impl SynchronizerManager {
return;
};
#[cfg(target_os = "macos")]
{
let _ = tokio::process::Command::new("osascript")
.arg("-e")
.arg(format!(
"tell application \"System Events\" to set frontmost of (first process whose unix id is {}) to true",
pid
))
.output()
.await;
}
#[cfg(target_os = "linux")]
{
let _ = tokio::process::Command::new("xdotool")
@@ -338,7 +331,7 @@ impl SynchronizerManager {
.await;
}
#[cfg(target_os = "windows")]
#[cfg(not(target_os = "linux"))]
{
let _ = pid;
}
+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");
}
+67 -50
View File
@@ -240,58 +240,77 @@ impl WireGuardSocks5Server {
socket: &UdpSocket,
peer_addr: SocketAddr,
) -> Result<(), VpnError> {
let mut dst = vec![0u8; 2048];
let result = tunn.format_handshake_initiation(&mut dst, false);
match result {
TunnResult::WriteToNetwork(packet) => {
socket
.send_to(packet, peer_addr)
.map_err(|e| VpnError::Connection(format!("Failed to send handshake: {e}")))?;
}
TunnResult::Err(e) => {
return Err(VpnError::Tunnel(format!(
"Handshake initiation failed: {e:?}"
)));
}
_ => {}
}
socket
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.map_err(|e| VpnError::Connection(format!("Failed to set timeout: {e}")))?;
let mut recv_buf = vec![0u8; 2048];
match socket.recv_from(&mut recv_buf) {
Ok((len, _)) => {
let result = tunn.decapsulate(None, &recv_buf[..len], &mut dst);
match result {
TunnResult::WriteToNetwork(response) => {
socket
.send_to(response, peer_addr)
.map_err(|e| VpnError::Connection(format!("Failed to send response: {e}")))?;
}
TunnResult::Done => {}
TunnResult::Err(e) => {
return Err(VpnError::Tunnel(format!(
"Handshake response failed: {e:?}"
)));
}
_ => {}
// WireGuard handshakes use UDP which can silently lose packets, especially
// through Docker port-forwarding layers. Retry the handshake initiation up
// to 5 times (25s total) before giving up — the protocol is designed for
// retransmission and peers handle duplicate initiations correctly.
let max_attempts = 5;
let mut last_error = String::from("no handshake attempt completed");
for attempt in 1..=max_attempts {
let mut dst = vec![0u8; 2048];
let result = tunn.format_handshake_initiation(&mut dst, false);
match result {
TunnResult::WriteToNetwork(packet) => {
socket
.send_to(packet, peer_addr)
.map_err(|e| VpnError::Connection(format!("Failed to send handshake: {e}")))?;
}
TunnResult::Err(e) => {
return Err(VpnError::Tunnel(format!(
"Handshake initiation failed: {e:?}"
)));
}
_ => {}
}
Err(e) => {
return Err(VpnError::Connection(format!(
"Handshake timeout or error: {e}"
)));
let mut recv_buf = vec![0u8; 2048];
match socket.recv_from(&mut recv_buf) {
Ok((len, _)) => {
let result = tunn.decapsulate(None, &recv_buf[..len], &mut dst);
match result {
TunnResult::WriteToNetwork(response) => {
socket
.send_to(response, peer_addr)
.map_err(|e| VpnError::Connection(format!("Failed to send response: {e}")))?;
}
TunnResult::Done => {}
TunnResult::Err(e) => {
last_error = format!("handshake response error: {e:?}");
log::warn!(
"[vpn-worker] Handshake attempt {attempt}/{max_attempts} failed: {last_error}"
);
continue;
}
_ => {}
}
socket
.set_read_timeout(None)
.map_err(|e| VpnError::Connection(format!("Failed to clear timeout: {e}")))?;
return Ok(());
}
Err(e) if attempt < max_attempts => {
log::warn!(
"[vpn-worker] Handshake attempt {attempt}/{max_attempts} timed out: {e}, retrying"
);
last_error = format!("timeout: {e}");
continue;
}
Err(e) => {
last_error = format!("timeout: {e}");
}
}
}
socket
.set_read_timeout(None)
.map_err(|e| VpnError::Connection(format!("Failed to clear timeout: {e}")))?;
Ok(())
Err(VpnError::Connection(format!(
"Handshake failed after {max_attempts} attempts: {last_error}"
)))
}
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
@@ -603,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;
}
_ => {}
}
+11 -3
View File
@@ -2,12 +2,13 @@ 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;
const VPN_WORKER_POLL_INTERVAL_MS: u64 = 100;
const VPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 10_000;
const VPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 30_000;
const OPENVPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 100_000;
async fn vpn_worker_accepting_connections(config: &VpnWorkerConfig) -> bool {
@@ -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)?;
+172 -62
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,10 +87,23 @@ impl WayfernManager {
inner: Arc::new(AsyncMutex::new(WayfernManagerInner {
instances: HashMap::new(),
})),
http_client: Client::new(),
http_client: Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("Failed to build reqwest client for wayfern_manager"),
}
}
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
}
@@ -141,19 +155,29 @@ impl WayfernManager {
let max_attempts = 120;
let delay = Duration::from_millis(500);
let mut last_error: Option<String> = None;
for attempt in 0..max_attempts {
match self.http_client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => {
log::info!("CDP ready on port {port} after {attempt} attempts");
return Ok(());
}
_ => {
Ok(resp) => {
last_error = Some(format!("HTTP {} from {url}", resp.status()));
tokio::time::sleep(delay).await;
}
Err(e) => {
last_error = Some(format!("request failed: {e}"));
tokio::time::sleep(delay).await;
}
}
}
Err(format!("CDP not ready after {max_attempts} attempts on port {port}").into())
let detail = last_error.unwrap_or_else(|| "no attempts completed".to_string());
// Log at error level so we can diagnose Windows/AV/firewall-induced CDP hangs
// in customer reports without needing them to reproduce in the moment.
log::error!("CDP not ready after {max_attempts} attempts on port {port}: {detail}");
Err(format!("CDP not ready after {max_attempts} attempts on port {port}: {detail}").into())
}
async fn get_cdp_targets(
@@ -243,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 {
@@ -270,6 +303,28 @@ 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}. {stderr_output}"
);
cleanup().await;
return Err(e);
}
@@ -464,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();
@@ -511,11 +576,11 @@ impl WayfernManager {
let name: String = row.get(0).unwrap_or_default();
let host: String = row.get(1).unwrap_or_default();
let encrypted: Vec<u8> = row.get(2).unwrap_or_default();
let decrypted =
crate::cookie_manager::chrome_decrypt::decrypt(
&encrypted,
&encryption_key,
);
let decrypted = crate::cookie_manager::chrome_decrypt::decrypt(
&encrypted,
&host,
&encryption_key,
);
match decrypted {
Some(val) => log::info!(
"Pre-launch: Cookie decryption SUCCEEDED for '{}' (host: {}, decrypted {} bytes)",
@@ -541,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(),
@@ -552,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(),
];
@@ -564,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());
@@ -580,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());
@@ -589,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());
@@ -701,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 {
@@ -749,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(),
@@ -756,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;
@@ -777,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)]
{
@@ -931,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.19.0",
"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(())
}
+65 -3
View File
@@ -90,8 +90,40 @@ pub async fn start_wireguard_server() -> Result<WireGuardTestConfig, String> {
));
}
// Wait for container to be ready and generate configs
sleep(Duration::from_secs(10)).await;
// Wait for container to generate configs and bring up the WireGuard interface.
// A fixed sleep is flaky — on busy machines the interface takes longer. Instead
// we poll `wg show` inside the container until it reports an active interface,
// with a generous upper bound.
let wg_ready_deadline = tokio::time::Instant::now() + Duration::from_secs(45);
loop {
sleep(Duration::from_secs(2)).await;
// Check if peer config file has been generated
let config_check = Command::new("docker")
.args(["exec", WG_CONTAINER, "cat", "/config/peer1/peer1.conf"])
.output();
let config_exists = config_check
.as_ref()
.map(|o| o.status.success())
.unwrap_or(false);
// Check if WireGuard interface is actually up and listening
let wg_check = Command::new("docker")
.args(["exec", WG_CONTAINER, "wg", "show"])
.output();
let wg_up = wg_check
.as_ref()
.map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains("listening port"))
.unwrap_or(false);
if config_exists && wg_up {
break;
}
if tokio::time::Instant::now() >= wg_ready_deadline {
return Err("WireGuard container did not become ready within 45s".to_string());
}
}
// Extract client config from container
let config_output = Command::new("docker")
@@ -107,7 +139,30 @@ pub async fn start_wireguard_server() -> Result<WireGuardTestConfig, String> {
}
let config_str = String::from_utf8_lossy(&config_output.stdout).to_string();
parse_wireguard_test_config(&config_str)
let mut config = parse_wireguard_test_config(&config_str)?;
// Start a lightweight HTTP server inside the container on the WireGuard
// interface so tests can verify traffic flows through the tunnel without
// depending on internet access (Docker Desktop for Mac can't reliably NAT
// WireGuard tunnel traffic to the internet). The linuxserver/wireguard
// image doesn't have python3 or busybox httpd, but it has nc (netcat).
let _ = Command::new("docker")
.args([
"exec",
"-d",
WG_CONTAINER,
"sh",
"-c",
r#"while true; do printf "HTTP/1.1 200 OK\r\nContent-Length: 13\r\nConnection: close\r\n\r\nWG-TUNNEL-OK\n" | nc -l -p 8080 2>/dev/null; done"#,
])
.output();
// Give the nc loop a moment to start accepting
sleep(Duration::from_millis(500)).await;
// Extract the server's tunnel IP (first octet group from INTERNAL_SUBNET + .1)
config.server_tunnel_ip = "10.64.0.1".to_string();
Ok(config)
}
/// Start an OpenVPN test server and return client config
@@ -282,6 +337,10 @@ pub struct WireGuardTestConfig {
pub peer_endpoint: String,
pub allowed_ips: Vec<String>,
pub preshared_key: Option<String>,
/// IP of the WireGuard server on the tunnel interface (e.g. 10.64.0.1).
/// Tests use this to reach an HTTP server inside the container without
/// needing internet access from Docker.
pub server_tunnel_ip: String,
}
/// OpenVPN test configuration
@@ -355,6 +414,7 @@ fn parse_wireguard_test_config(content: &str) -> Result<WireGuardTestConfig, Str
peer_endpoint,
allowed_ips,
preshared_key,
server_tunnel_ip: String::new(), // filled in by caller
})
}
@@ -382,6 +442,8 @@ fn get_ci_wireguard_config(host: &str, port: &str) -> Result<WireGuardTestConfig
peer_endpoint: format!("{host}:{port}"),
allowed_ips: vec!["0.0.0.0/0".to_string()],
preshared_key: std::env::var("VPN_TEST_WG_PRESHARED_KEY").ok(),
server_tunnel_ip: std::env::var("VPN_TEST_WG_SERVER_IP")
.unwrap_or_else(|_| "10.0.0.1".to_string()),
})
}
+35 -42
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);
@@ -487,7 +487,6 @@ impl Drop for TestEnvGuard {
struct ProxyProcess {
id: String,
local_port: u16,
local_url: String,
}
async fn ensure_donut_proxy_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
@@ -664,10 +663,6 @@ async fn start_proxy_with_upstream(
Ok(ProxyProcess {
id: config["id"].as_str().ok_or("Missing proxy id")?.to_string(),
local_port: config["localPort"].as_u64().ok_or("Missing local port")? as u16,
local_url: config["localUrl"]
.as_str()
.ok_or("Missing local URL")?
.to_string(),
})
}
@@ -696,28 +691,23 @@ async fn raw_http_request_via_proxy(
url: &str,
host_header: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let mut stream = tokio::time::timeout(
Duration::from_secs(20),
TcpStream::connect(("127.0.0.1", local_port)),
)
.await
.map_err(|_| "proxy TCP connect timed out after 20s")??;
let request = format!("GET {url} HTTP/1.1\r\nHost: {host_header}\r\nConnection: close\r\n\r\n");
stream.write_all(request.as_bytes()).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
tokio::time::timeout(Duration::from_secs(20), stream.read_to_end(&mut response))
.await
.map_err(|_| "proxy HTTP response timed out after 20s")??;
Ok(String::from_utf8_lossy(&response).to_string())
}
async fn https_get_via_proxy(
local_proxy_url: &str,
url: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.no_proxy()
.proxy(reqwest::Proxy::all(local_proxy_url)?)
.build()?;
Ok(client.get(url).send().await?.text().await?)
}
async fn cleanup_runtime() {
let _ = donutbrowser_lib::proxy_runner::stop_all_proxy_processes().await;
let _ = donutbrowser_lib::vpn_worker_runner::stop_all_vpn_workers().await;
@@ -744,6 +734,7 @@ async fn wait_for_file(
async fn run_proxy_feature_suite(
binary_path: &PathBuf,
vpn_id: &str,
server_tunnel_ip: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let vpn_worker = donutbrowser_lib::vpn_worker_runner::start_vpn_worker(vpn_id)
.await
@@ -759,20 +750,20 @@ async fn run_proxy_feature_suite(
sleep(Duration::from_millis(500)).await;
// Test HTTP traffic through the tunnel to the internal HTTP server running
// inside the WireGuard container. This avoids depending on internet access
// from Docker (macOS Docker Desktop can't reliably NAT WireGuard tunnel
// traffic through to the internet).
let internal_url = format!("http://{}:8080/", server_tunnel_ip);
let internal_host = format!("{}:8080", server_tunnel_ip);
let http_response =
raw_http_request_via_proxy(proxy.local_port, "http://example.com/", "example.com").await?;
raw_http_request_via_proxy(proxy.local_port, &internal_url, &internal_host).await?;
assert!(
http_response.contains("Example Domain"),
"HTTP traffic through donut-proxy+VPN should succeed, got: {}",
http_response.contains("WG-TUNNEL-OK"),
"HTTP traffic through donut-proxy+VPN tunnel should succeed, got: {}",
&http_response[..http_response.len().min(300)]
);
let https_body = https_get_via_proxy(&proxy.local_url, "https://example.com/").await?;
assert!(
https_body.contains("Example Domain"),
"HTTPS traffic through donut-proxy+VPN should succeed"
);
let stats_file = donutbrowser_lib::app_dirs::cache_dir()
.join("traffic_stats")
.join(format!("{}.json", profile_id));
@@ -792,14 +783,16 @@ async fn run_proxy_feature_suite(
.as_object()
.ok_or("Traffic stats are missing per-domain data")?;
assert!(
domains.contains_key("example.com"),
"Traffic stats should include example.com domain activity"
domains.contains_key(server_tunnel_ip),
"Traffic stats should include tunnel server IP activity, got: {:?}",
domains.keys().collect::<Vec<_>>()
);
stop_proxy(binary_path, &proxy.id).await?;
// DNS blocklist test: blocklist the tunnel server IP so it gets rejected
let blocklist_file = tempfile::NamedTempFile::new()?;
std::fs::write(blocklist_file.path(), "example.com\n")?;
std::fs::write(blocklist_file.path(), format!("{server_tunnel_ip}\n"))?;
let blocked_proxy = start_proxy_with_upstream(
binary_path,
&vpn_upstream,
@@ -808,12 +801,8 @@ async fn run_proxy_feature_suite(
None,
)
.await?;
let blocked_response = raw_http_request_via_proxy(
blocked_proxy.local_port,
"http://example.com/",
"example.com",
)
.await?;
let blocked_response =
raw_http_request_via_proxy(blocked_proxy.local_port, &internal_url, &internal_host).await?;
assert!(
blocked_response.contains("403") || blocked_response.contains("Blocked by DNS blocklist"),
"DNS blocklist should be enforced before forwarding to the VPN upstream"
@@ -875,8 +864,8 @@ async fn run_proxy_feature_suite(
async fn test_wireguard_traffic_flows_through_donut_proxy(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let _env = TestEnvGuard::new()?;
cleanup_runtime().await;
cleanup_runtime().await;
if !test_harness::is_docker_available() {
eprintln!("skipping WireGuard e2e test because Docker is unavailable");
return Ok(());
@@ -901,8 +890,10 @@ async fn test_wireguard_traffic_flows_through_donut_proxy(
storage.save_config(&vpn_config)?;
}
let result = run_proxy_feature_suite(&binary_path, &vpn_config.id).await;
let result =
run_proxy_feature_suite(&binary_path, &vpn_config.id, &wg_config.server_tunnel_ip).await;
cleanup_runtime().await;
result
}
@@ -952,7 +943,9 @@ async fn test_openvpn_traffic_flows_through_donut_proxy(
storage.save_config(&vpn_config)?;
}
let result = run_proxy_feature_suite(&binary_path, &vpn_config.id).await;
// OpenVPN test uses the server's tunnel IP for internal-only traffic.
// The OpenVPN server's subnet is 10.9.0.0/24, server at 10.9.0.1.
let result = run_proxy_feature_suite(&binary_path, &vpn_config.id, "10.9.0.1").await;
cleanup_runtime().await;
result
}
+2
View File
@@ -516,6 +516,7 @@ export default function Home() {
extensionGroupId?: string;
ephemeral?: boolean;
dnsBlocklist?: string;
launchHook?: string;
}) => {
try {
const profile = await invoke<BrowserProfile>(
@@ -534,6 +535,7 @@ export default function Home() {
(selectedGroupId !== "default" ? selectedGroupId : undefined),
ephemeral: profileData.ephemeral,
dnsBlocklist: profileData.dnsBlocklist,
launchHook: profileData.launchHook,
},
);
+26 -17
View File
@@ -77,11 +77,16 @@ export function CookieCopyDialog({
);
const [error, setError] = useState<string | null>(null);
// Never offer a selected profile as a source — you can't copy a profile's
// cookies onto itself, and including it here would leave the user in a
// dead-end state (source picked = target list empty = copy button disabled).
const eligibleSourceProfiles = useMemo(() => {
return profiles.filter(
(p) => p.browser === "wayfern" || p.browser === "camoufox",
(p) =>
!selectedProfiles.includes(p.id) &&
(p.browser === "wayfern" || p.browser === "camoufox"),
);
}, [profiles]);
}, [profiles, selectedProfiles]);
const targetProfiles = useMemo(() => {
return profiles.filter(
@@ -148,22 +153,21 @@ export function CookieCopyDialog({
const toggleDomain = useCallback(
(domain: string, cookies: UnifiedCookie[]) => {
setSelection((prev) => {
const current = prev[domain];
const allSelected = current.allSelected;
if (allSelected) {
// `prev[domain]` is `undefined` for any domain not yet interacted with
// and after the user fully deselects it (toggleCookie deletes the
// entry on empty). Treat missing as "not selected".
if (prev[domain]?.allSelected) {
const newSelection = { ...prev };
delete newSelection[domain];
return newSelection;
} else {
return {
...prev,
[domain]: {
allSelected: true,
cookies: new Set(cookies.map((c) => c.name)),
},
};
}
return {
...prev,
[domain]: {
allSelected: true,
cookies: new Set(cookies.map((c) => c.name)),
},
};
});
},
[],
@@ -503,9 +507,13 @@ function DomainRow({
onToggleCookie,
onToggleExpand,
}: DomainRowProps) {
// `selection[domain.domain]` is `undefined` for domains the user hasn't
// touched yet (initial state after loading cookies is `{}`) and for any
// domain the user fully deselected (toggleCookie deletes the entry on
// empty). Default to "no cookies selected" instead of crashing.
const domainSelection = selection[domain.domain];
const isAllSelected = domainSelection.allSelected;
const selectedCount = domainSelection.cookies.size;
const isAllSelected = domainSelection?.allSelected ?? false;
const selectedCount = domainSelection?.cookies.size ?? 0;
const isPartial =
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
@@ -540,7 +548,8 @@ function DomainRow({
{isExpanded && (
<div className="ml-8 pl-2 border-l space-y-1">
{domain.cookies.map((cookie) => {
const isSelected = domainSelection.cookies.has(cookie.name);
const isSelected =
domainSelection?.cookies.has(cookie.name) ?? false;
return (
<div
key={`${domain.domain}-${cookie.name}`}
+9 -5
View File
@@ -309,8 +309,11 @@ export function CookieManagementDialog({
const toggleDomain = useCallback(
(domain: string, cookies: UnifiedCookie[]) => {
setExportSelection((prev) => {
const current = prev[domain];
if (current.allSelected) {
// `prev[domain]` is `undefined` when the domain was previously fully
// deselected (entries are deleted on empty — see toggleCookie). Treat
// missing as "not selected" so re-enabling falls through to the add
// branch instead of crashing on `.allSelected`.
if (prev[domain]?.allSelected) {
const next = { ...prev };
delete next[domain];
return next;
@@ -592,8 +595,8 @@ function ExportDomainRow({
onToggleExpand,
}: ExportDomainRowProps) {
const domainSelection = selection[domain.domain];
const isAllSelected = domainSelection.allSelected;
const selectedCount = domainSelection.cookies.size;
const isAllSelected = domainSelection?.allSelected ?? false;
const selectedCount = domainSelection?.cookies.size ?? 0;
const isPartial =
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
@@ -628,7 +631,8 @@ function ExportDomainRow({
{isExpanded && (
<div className="ml-7 pl-2 border-l space-y-0.5">
{domain.cookies.map((cookie) => {
const isSelected = domainSelection.cookies.has(cookie.name);
const isSelected =
domainSelection?.cookies.has(cookie.name) ?? false;
return (
<div
key={`${domain.domain}-${cookie.name}`}
+42
View File
@@ -85,6 +85,7 @@ interface CreateProfileDialogProps {
extensionGroupId?: string;
ephemeral?: boolean;
dnsBlocklist?: string;
launchHook?: string;
}) => Promise<void>;
selectedGroupId?: string;
crossOsUnlocked?: boolean;
@@ -126,6 +127,7 @@ export function CreateProfileDialog({
const [selectedProxyId, setSelectedProxyId] = useState<string>();
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
const [dnsBlocklist, setDnsBlocklist] = useState<string>("");
const [launchHook, setLaunchHook] = useState("");
// Camoufox anti-detect states
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
@@ -150,6 +152,7 @@ export function CreateProfileDialog({
setSelectedBrowser(null);
setProfileName("");
setSelectedProxyId(undefined);
setLaunchHook("");
};
const handleTabChange = (value: string) => {
@@ -158,6 +161,7 @@ export function CreateProfileDialog({
setSelectedBrowser(null);
setProfileName("");
setSelectedProxyId(undefined);
setLaunchHook("");
};
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
@@ -398,6 +402,7 @@ export function CreateProfileDialog({
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
});
} else {
// Default to Camoufox
@@ -424,6 +429,7 @@ export function CreateProfileDialog({
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
});
}
} else {
@@ -448,6 +454,7 @@ export function CreateProfileDialog({
proxyId: selectedProxyId,
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
});
}
@@ -469,6 +476,7 @@ export function CreateProfileDialog({
setActiveTab("anti-detect");
setSelectedBrowser(null);
setSelectedProxyId(undefined);
setLaunchHook("");
setReleaseTypes({});
setIsLoadingReleaseTypes(false);
setReleaseTypesError(null);
@@ -1167,6 +1175,23 @@ export function CreateProfileDialog({
)}
</div>
<div className="space-y-2">
<Label htmlFor="launch-hook-url">
{t("createProfile.launchHook.label")}
</Label>
<Input
id="launch-hook-url"
value={launchHook}
onChange={(e) => {
setLaunchHook(e.target.value);
}}
placeholder={t(
"createProfile.launchHook.placeholder",
)}
disabled={isCreating}
/>
</div>
{/* DNS Blocklist */}
<div className="space-y-2">
<Label>{t("dnsBlocklist.title")}</Label>
@@ -1498,6 +1523,23 @@ export function CreateProfileDialog({
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="launch-hook-url-regular">
{t("createProfile.launchHook.label")}
</Label>
<Input
id="launch-hook-url-regular"
value={launchHook}
onChange={(e) => {
setLaunchHook(e.target.value);
}}
placeholder={t(
"createProfile.launchHook.placeholder",
)}
disabled={isCreating}
/>
</div>
</div>
</TabsContent>
</>
+3 -1
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import {
@@ -31,6 +32,7 @@ export function DeleteGroupDialog({
group,
onGroupDeleted,
}: DeleteGroupDialogProps) {
const { t } = useTranslation();
const [associatedProfiles, setAssociatedProfiles] = useState<
BrowserProfile[]
>([]);
@@ -155,7 +157,7 @@ export function DeleteGroupDialog({
<div className="flex items-center space-x-2">
<RadioGroupItem value="move" id="move" />
<Label htmlFor="move" className="text-sm">
Move profiles to Default group
{t("groups.moveToDefault")}
</Label>
</div>
<div className="flex items-center space-x-2">
+11 -3
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { toast } from "sonner";
import { CreateGroupDialog } from "@/components/create-group-dialog";
@@ -40,6 +41,7 @@ export function GroupAssignmentDialog({
onAssignmentComplete,
profiles = [],
}: GroupAssignmentDialogProps) {
const { t } = useTranslation();
const [groups, setGroups] = useState<ProfileGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -72,10 +74,13 @@ export function GroupAssignmentDialog({
const groupName = selectedGroupId
? groups.find((g) => g.id === selectedGroupId)?.name || "Unknown Group"
: "Default";
: t("groups.defaultGroup");
toast.success(
`Successfully assigned ${selectedProfiles.length} profile(s) to ${groupName}`,
t("groups.assignSuccess", {
count: selectedProfiles.length,
group: groupName,
}),
);
onAssignmentComplete();
onClose();
@@ -96,6 +101,7 @@ export function GroupAssignmentDialog({
groups,
onAssignmentComplete,
onClose,
t,
]);
useEffect(() => {
@@ -166,7 +172,9 @@ export function GroupAssignmentDialog({
<SelectValue placeholder="Select a group" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default (No Group)</SelectItem>
<SelectItem value="default">
{t("groups.defaultGroupNoGroup")}
</SelectItem>
{groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
+5 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import type { GroupWithCount } from "@/types";
@@ -18,6 +19,7 @@ export function GroupBadges({
groups,
isLoading,
}: GroupBadgesProps) {
const { t } = useTranslation();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftFade, setShowLeftFade] = useState(false);
const [showRightFade, setShowRightFade] = useState(false);
@@ -181,7 +183,9 @@ export function GroupBadges({
}
}}
>
<span>{group.name}</span>
<span>
{group.id === "default" ? t("groups.defaultGroup") : group.name}
</span>
<span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm">
{group.count}
</span>
+6 -6
View File
@@ -3,6 +3,7 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuPencil, LuTrash2 } from "react-icons/lu";
import { CreateGroupDialog } from "@/components/create-group-dialog";
@@ -90,6 +91,7 @@ export function GroupManagementDialog({
onClose,
onGroupManagementComplete,
}: GroupManagementDialogProps) {
const { t } = useTranslation();
const [groups, setGroups] = useState<GroupWithCount[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -233,10 +235,9 @@ export function GroupManagementDialog({
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Manage Profile Groups</DialogTitle>
<DialogTitle>{t("groups.management")}</DialogTitle>
<DialogDescription>
Create, edit, and delete profile groups. Profiles without a group
will appear in the "Default" group.
{t("groups.noGroupDescription")}
</DialogDescription>
</DialogHeader>
@@ -265,12 +266,11 @@ export function GroupManagementDialog({
{/* Groups list */}
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading groups...
{t("common.loading")}
</div>
) : groups.length === 0 ? (
<div className="text-sm text-muted-foreground">
No groups created yet. Create your first group using the button
above.
{t("groups.noGroupsDescription")}
</div>
) : (
<div className="border rounded-md">
+64 -5
View File
@@ -243,14 +243,73 @@ export function IntegrationsDialog({
<div className="space-y-2">
<Label className="text-sm font-medium">Port</Label>
<div className="flex items-center space-x-2">
<Button
size="sm"
disabled={
isApiStarting || apiServerPort === settings.api_port
}
onClick={async () => {
const port = settings.api_port;
if (port < 1 || port > 65535) {
showErrorToast("Invalid port", {
description: "Port must be between 1 and 65535",
});
return;
}
setIsApiStarting(true);
try {
await invoke("stop_api_server");
const next = await invoke<AppSettings>(
"save_app_settings",
{ settings },
);
setSettings(next);
const actualPort = await invoke<number>(
"start_api_server",
{ port },
);
setApiServerPort(actualPort);
if (actualPort !== port) {
showErrorToast(`Port ${port} is already in use`, {
description: `Server started on fallback port ${actualPort}`,
});
} else {
showSuccessToast(
`API server running on port ${actualPort}`,
);
}
} catch (e) {
showErrorToast("Failed to start API server", {
description:
e instanceof Error
? e.message
: "Unknown error",
});
} finally {
setIsApiStarting(false);
}
}}
>
{t("common.buttons.save")}
</Button>
<Input
value={apiServerPort ?? settings.api_port}
readOnly
type="number"
value={settings.api_port}
onChange={(e) => {
const val = Number.parseInt(e.target.value, 10);
if (!Number.isNaN(val)) {
setSettings({ ...settings, api_port: val });
}
}}
className="w-24 font-mono"
min={1}
max={65535}
/>
<span className="text-xs text-muted-foreground">
Server is running
</span>
{apiServerPort && (
<span className="text-xs text-muted-foreground">
{t("common.status.running")}
</span>
)}
</div>
</div>
+14
View File
@@ -33,6 +33,7 @@ import {
ProfileBypassRulesDialog,
ProfileDnsBlocklistDialog,
ProfileInfoDialog,
ProfileLaunchHookDialog,
} from "@/components/profile-info-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -937,6 +938,8 @@ export function ProfilesDataTable({
React.useState<BrowserProfile | null>(null);
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
React.useState<BrowserProfile | null>(null);
const [launchHookProfile, setLaunchHookProfile] =
React.useState<BrowserProfile | null>(null);
const [launchingProfiles, setLaunchingProfiles] = React.useState<Set<string>>(
new Set(),
);
@@ -2680,6 +2683,9 @@ export function ProfilesDataTable({
onOpenDnsBlocklist={(profile) => {
setDnsBlocklistProfile(profile);
}}
onOpenLaunchHook={(profile) => {
setLaunchHookProfile(profile);
}}
onCloneProfile={onCloneProfile}
onLaunchWithSync={onLaunchWithSync}
onDeleteProfile={(profile) => {
@@ -2770,6 +2776,14 @@ export function ProfilesDataTable({
profileId={dnsBlocklistProfile?.id ?? null}
currentLevel={dnsBlocklistProfile?.dns_blocklist ?? null}
/>
<ProfileLaunchHookDialog
isOpen={launchHookProfile !== null}
onClose={() => {
setLaunchHookProfile(null);
}}
profileId={launchHookProfile?.id ?? null}
currentLaunchHook={launchHookProfile?.launch_hook ?? null}
/>
</>
);
}
+126 -26
View File
@@ -13,6 +13,7 @@ import {
LuFingerprint,
LuGlobe,
LuGroup,
LuLink,
LuPlus,
LuPuzzle,
LuRefreshCw,
@@ -66,6 +67,7 @@ interface ProfileInfoDialogProps {
onAssignExtensionGroup?: (profileIds: string[]) => void;
onOpenBypassRules?: (profile: BrowserProfile) => void;
onOpenDnsBlocklist?: (profile: BrowserProfile) => void;
onOpenLaunchHook?: (profile: BrowserProfile) => void;
onCloneProfile?: (profile: BrowserProfile) => void;
onDeleteProfile?: (profile: BrowserProfile) => void;
onLaunchWithSync?: (profile: BrowserProfile) => void;
@@ -113,6 +115,7 @@ export function ProfileInfoDialog({
onAssignExtensionGroup,
onOpenBypassRules,
onOpenDnsBlocklist,
onOpenLaunchHook,
onCloneProfile,
onDeleteProfile,
onLaunchWithSync,
@@ -218,6 +221,13 @@ export function ProfileInfoDialog({
const hasNote = !!profile.note;
const showCrossOs = isCrossOsProfile(profile);
// Items in the settings tab `actions` list MUST only open another dialog
// (or trigger a navigation/action that closes this one). Do NOT put inline
// settings UI — inputs, toggles, save buttons — directly in this dialog's
// settings tab. Each setting belongs in its own focused dialog (see
// `ProfileLaunchHookDialog`, `ProfileBypassRulesDialog`,
// `ProfileDnsBlocklistDialog` for the pattern). The settings tab is purely
// a navigation hub.
interface ActionItem {
icon: React.ReactNode;
label: string;
@@ -336,6 +346,14 @@ export function ProfileInfoDialog({
handleAction(() => onOpenDnsBlocklist?.(profile));
},
},
{
icon: <LuLink className="w-4 h-4" />,
label: t("profiles.actions.launchHook"),
onClick: () => {
handleAction(() => onOpenLaunchHook?.(profile));
},
hidden: !onOpenLaunchHook,
},
{
icon: <LuTrash2 className="w-4 h-4" />,
label: t("profiles.actions.delete"),
@@ -474,6 +492,10 @@ export function ProfileInfoDialog({
: t("dnsBlocklist.none")
}
/>
<InfoCard
label={t("profileInfo.fields.launchHook")}
value={profile.launch_hook || t("profileInfo.values.none")}
/>
</div>
{/* Sync */}
@@ -546,33 +568,37 @@ export function ProfileInfoDialog({
</TabsContent>
<TabsContent value="settings">
<div className="overflow-y-auto max-h-[calc(80vh-12rem)]">
<div className="flex flex-col py-1">
{visibleActions.map((action) => (
<button
key={action.label}
type="button"
disabled={action.disabled}
onClick={action.onClick}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left w-full",
"hover:bg-accent disabled:opacity-50 disabled:pointer-events-none",
action.destructive &&
"text-destructive hover:bg-destructive/10",
)}
>
{action.icon}
<span className="flex-1 flex items-center gap-2">
{action.label}
{action.runningBadge && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold rounded bg-primary/15 text-primary uppercase">
{t("common.status.running")}
</span>
<div className="flex flex-col gap-3 py-1">
<div className="flex flex-col">
{visibleActions.map((action) => (
<button
key={action.label}
type="button"
disabled={action.disabled}
onClick={action.onClick}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left w-full",
"hover:bg-accent disabled:opacity-50 disabled:pointer-events-none",
action.destructive &&
"text-destructive hover:bg-destructive/10",
)}
{action.proBadge && !action.runningBadge && <ProBadge />}
</span>
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
>
{action.icon}
<span className="flex-1 flex items-center gap-2">
{action.label}
{action.runningBadge && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold rounded bg-primary/15 text-primary uppercase">
{t("common.status.running")}
</span>
)}
{action.proBadge && !action.runningBadge && (
<ProBadge />
)}
</span>
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
</div>
</div>
</div>
</TabsContent>
@@ -582,6 +608,80 @@ export function ProfileInfoDialog({
);
}
interface ProfileLaunchHookDialogProps {
isOpen: boolean;
onClose: () => void;
profileId: string | null;
currentLaunchHook: string | null;
}
export function ProfileLaunchHookDialog({
isOpen,
onClose,
profileId,
currentLaunchHook,
}: ProfileLaunchHookDialogProps) {
const { t } = useTranslation();
const [value, setValue] = React.useState(currentLaunchHook ?? "");
const [isSaving, setIsSaving] = React.useState(false);
React.useEffect(() => {
if (isOpen) {
setValue(currentLaunchHook ?? "");
}
}, [isOpen, currentLaunchHook]);
const trimmed = value.trim();
const saved = currentLaunchHook ?? "";
const isDirty = trimmed !== saved;
const handleSave = async () => {
if (!profileId) return;
setIsSaving(true);
try {
await invoke("update_profile_launch_hook", {
profileId,
launchHook: trimmed || null,
});
onClose();
} catch (err) {
console.error("Failed to update launch hook:", err);
} finally {
setIsSaving(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("profileInfo.launchHook.title")}</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground">
{t("profileInfo.launchHook.description")}
</p>
<Input
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
placeholder={t("profileInfo.launchHook.placeholder")}
disabled={isSaving}
/>
<DialogFooter>
<Button
onClick={() => void handleSave()}
disabled={isSaving || !isDirty}
className="w-full"
>
{t("common.buttons.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface ProfileDnsBlocklistDialogProps {
isOpen: boolean;
onClose: () => void;
+1 -3
View File
@@ -50,9 +50,7 @@ export function ProxyCheckButton({
try {
const result = await invoke<ProxyCheckResult>("check_proxy_validity", {
proxyId: proxy.id,
proxySettings: proxy.dynamic_proxy_url
? undefined
: proxy.proxy_settings,
proxySettings: proxy.proxy_settings,
});
setLocalResult(result);
onCheckComplete?.(result);
+179 -357
View File
@@ -21,11 +21,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { ProxySettings, StoredProxy } from "@/types";
import type { StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface RegularFormData {
interface ProxyFormData {
name: string;
proxy_type: string;
host: string;
@@ -34,20 +33,21 @@ interface RegularFormData {
password: string;
}
interface DynamicFormData {
name: string;
url: string;
format: string;
}
type ProxyMode = "regular" | "dynamic";
interface ProxyFormDialogProps {
isOpen: boolean;
onClose: () => void;
editingProxy?: StoredProxy | null;
}
const DEFAULT_FORM: ProxyFormData = {
name: "",
proxy_type: "http",
host: "",
port: 8080,
username: "",
password: "",
};
export function ProxyFormDialog({
isOpen,
onClose,
@@ -55,158 +55,79 @@ export function ProxyFormDialog({
}: ProxyFormDialogProps) {
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [mode, setMode] = useState<ProxyMode>("regular");
const [regularForm, setRegularForm] = useState<RegularFormData>({
name: "",
proxy_type: "http",
host: "",
port: 8080,
username: "",
password: "",
});
const [dynamicForm, setDynamicForm] = useState<DynamicFormData>({
name: "",
url: "",
format: "json",
});
const [form, setForm] = useState<ProxyFormData>(DEFAULT_FORM);
const resetForm = useCallback(() => {
setRegularForm({
name: "",
proxy_type: "http",
host: "",
port: 8080,
username: "",
password: "",
});
setDynamicForm({
name: "",
url: "",
format: "json",
});
setMode("regular");
setForm(DEFAULT_FORM);
}, []);
useEffect(() => {
if (isOpen) {
if (editingProxy) {
if (editingProxy.dynamic_proxy_url) {
setMode("dynamic");
setDynamicForm({
name: editingProxy.name,
url: editingProxy.dynamic_proxy_url,
format: editingProxy.dynamic_proxy_format || "json",
});
} else {
setMode("regular");
setRegularForm({
name: editingProxy.name,
proxy_type: editingProxy.proxy_settings.proxy_type,
host: editingProxy.proxy_settings.host,
port: editingProxy.proxy_settings.port,
username: editingProxy.proxy_settings.username ?? "",
password: editingProxy.proxy_settings.password ?? "",
});
}
} else {
resetForm();
}
}
}, [isOpen, editingProxy, resetForm]);
const handleTestDynamic = useCallback(async () => {
if (!dynamicForm.url.trim()) {
toast.error(t("proxies.dynamic.urlRequired"));
if (!isOpen) {
return;
}
setIsTesting(true);
try {
const settings = await invoke<ProxySettings>("fetch_dynamic_proxy", {
url: dynamicForm.url.trim(),
format: dynamicForm.format,
});
toast.success(
t("proxies.dynamic.testSuccess", {
host: settings.host,
port: settings.port,
}),
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(t("proxies.dynamic.testFailed", { error: errorMessage }));
} finally {
setIsTesting(false);
if (!editingProxy) {
resetForm();
return;
}
}, [dynamicForm, t]);
setForm({
name: editingProxy.name,
proxy_type: editingProxy.proxy_settings.proxy_type,
host: editingProxy.proxy_settings.host,
port: editingProxy.proxy_settings.port,
username: editingProxy.proxy_settings.username ?? "",
password: editingProxy.proxy_settings.password ?? "",
});
}, [editingProxy, isOpen, resetForm]);
const handleSubmit = useCallback(async () => {
if (mode === "regular") {
if (!regularForm.name.trim()) {
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
return;
}
if (!regularForm.host.trim() || !regularForm.port) {
toast.error(
t("proxies.form.hostPortRequired", "Host and port are required"),
);
return;
}
} else {
if (!dynamicForm.name.trim()) {
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
return;
}
if (!dynamicForm.url.trim()) {
toast.error(t("proxies.dynamic.urlRequired"));
return;
}
if (!form.name.trim()) {
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
return;
}
if (!form.host.trim() || !form.port) {
toast.error(
t("proxies.form.hostPortRequired", "Host and port are required"),
);
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 = {
name: form.name.trim(),
proxySettings: {
proxy_type: form.proxy_type,
host: form.host.trim(),
port: form.port,
username: form.username.trim() || undefined,
password: form.password.trim() || undefined,
},
};
if (editingProxy) {
if (mode === "dynamic") {
await invoke("update_stored_proxy", {
proxyId: editingProxy.id,
name: dynamicForm.name.trim(),
dynamicProxyUrl: dynamicForm.url.trim(),
dynamicProxyFormat: dynamicForm.format,
});
} else {
await invoke("update_stored_proxy", {
proxyId: editingProxy.id,
name: regularForm.name.trim(),
proxySettings: {
proxy_type: regularForm.proxy_type,
host: regularForm.host.trim(),
port: regularForm.port,
username: regularForm.username.trim() || undefined,
password: regularForm.password.trim() || undefined,
},
});
}
await invoke("update_stored_proxy", {
proxyId: editingProxy.id,
...payload,
});
toast.success(t("toasts.success.proxyUpdated"));
} else {
if (mode === "dynamic") {
await invoke("create_stored_proxy", {
name: dynamicForm.name.trim(),
dynamicProxyUrl: dynamicForm.url.trim(),
dynamicProxyFormat: dynamicForm.format,
});
} else {
await invoke("create_stored_proxy", {
name: regularForm.name.trim(),
proxySettings: {
proxy_type: regularForm.proxy_type,
host: regularForm.host.trim(),
port: regularForm.port,
username: regularForm.username.trim() || undefined,
password: regularForm.password.trim() || undefined,
},
});
}
await invoke("create_stored_proxy", payload);
toast.success(t("toasts.success.proxyCreated"));
}
@@ -219,7 +140,7 @@ export function ProxyFormDialog({
} finally {
setIsSubmitting(false);
}
}, [mode, regularForm, dynamicForm, editingProxy, onClose, t]);
}, [editingProxy, form, onClose, t]);
const handleClose = useCallback(() => {
if (!isSubmitting) {
@@ -227,17 +148,13 @@ export function ProxyFormDialog({
}
}, [isSubmitting, onClose]);
const isRegularValid =
regularForm.name.trim() &&
regularForm.host.trim() &&
regularForm.port > 0 &&
regularForm.port <= 65535;
const isDynamicValid = dynamicForm.name.trim() && dynamicForm.url.trim();
const isFormValid = mode === "regular" ? isRegularValid : isDynamicValid;
const isEditingDynamic = editingProxy?.dynamic_proxy_url != null;
const isFormValid =
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}>
@@ -249,210 +166,115 @@ export function ProxyFormDialog({
</DialogHeader>
<div className="grid gap-4 py-4">
{!editingProxy && (
<Tabs
value={mode}
onValueChange={(v) => {
setMode(v as ProxyMode);
<div className="grid gap-2">
<Label htmlFor="proxy-name">{t("proxies.form.name")}</Label>
<Input
id="proxy-name"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
}}
placeholder={t("proxies.form.namePlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label>{t("proxies.form.type")}</Label>
<Select
value={form.proxy_type}
onValueChange={(value) => {
setForm({ ...form, proxy_type: value });
}}
disabled={isSubmitting}
>
<TabsList className="w-full">
<TabsTrigger value="regular" className="flex-1">
{t("proxies.tabs.regular")}
</TabsTrigger>
<TabsTrigger value="dynamic" className="flex-1">
{t("proxies.tabs.dynamic")}
</TabsTrigger>
</TabsList>
</Tabs>
)}
<SelectTrigger>
<SelectValue placeholder="Select proxy type" />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5", "ss"].map((type) => (
<SelectItem key={type} value={type}>
{type === "ss" ? "Shadowsocks" : type.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{editingProxy && isEditingDynamic && (
<p className="text-xs text-muted-foreground">
{t("proxies.dynamic.description")}
</p>
)}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-host">{t("proxies.form.host")}</Label>
<Input
id="proxy-host"
value={form.host}
onChange={(e) => {
setForm({ ...form, host: e.target.value });
}}
placeholder={t("proxies.form.hostPlaceholder")}
disabled={isSubmitting}
/>
</div>
{mode === "regular" ? (
<>
<div className="grid gap-2">
<Label htmlFor="proxy-name">{t("proxies.form.name")}</Label>
<Input
id="proxy-name"
value={regularForm.name}
onChange={(e) => {
setRegularForm({ ...regularForm, name: e.target.value });
}}
placeholder="e.g. Office Proxy, Home VPN, etc."
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">{t("proxies.form.port")}</Label>
<Input
id="proxy-port"
type="number"
value={form.port}
onChange={(e) => {
setForm({
...form,
port: Number.parseInt(e.target.value, 10) || 0,
});
}}
placeholder={t("proxies.form.portPlaceholder")}
min="1"
max="65535"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid gap-2">
<Label>{t("proxies.form.type")}</Label>
<Select
value={regularForm.proxy_type}
onValueChange={(value) => {
setRegularForm({ ...regularForm, proxy_type: value });
}}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue placeholder="Select proxy type" />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5"].map((type) => (
<SelectItem key={type} value={type}>
{type.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-username">
{form.proxy_type === "ss"
? t("proxies.form.cipher")
: `${t("proxies.form.username")} (${t("proxies.form.usernamePlaceholder")})`}
</Label>
<Input
id="proxy-username"
value={form.username}
onChange={(e) => {
setForm({ ...form, username: e.target.value });
}}
placeholder={
form.proxy_type === "ss"
? t("proxies.form.cipherPlaceholder")
: t("proxies.form.usernamePlaceholder")
}
disabled={isSubmitting}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-host">{t("proxies.form.host")}</Label>
<Input
id="proxy-host"
value={regularForm.host}
onChange={(e) => {
setRegularForm({ ...regularForm, host: e.target.value });
}}
placeholder={t("proxies.form.hostPlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">{t("proxies.form.port")}</Label>
<Input
id="proxy-port"
type="number"
value={regularForm.port}
onChange={(e) => {
setRegularForm({
...regularForm,
port: parseInt(e.target.value, 10) || 0,
});
}}
placeholder={t("proxies.form.portPlaceholder")}
min="1"
max="65535"
disabled={isSubmitting}
/>
</div>
</div>
<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")})
</Label>
<Input
id="proxy-username"
value={regularForm.username}
onChange={(e) => {
setRegularForm({
...regularForm,
username: e.target.value,
});
}}
placeholder={t("proxies.form.usernamePlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-password">
{t("proxies.form.password")} (
{t("proxies.form.passwordPlaceholder")})
</Label>
<Input
id="proxy-password"
type="password"
value={regularForm.password}
onChange={(e) => {
setRegularForm({
...regularForm,
password: e.target.value,
});
}}
placeholder={t("proxies.form.passwordPlaceholder")}
disabled={isSubmitting}
/>
</div>
</div>
</>
) : (
<>
<div className="grid gap-2">
<Label htmlFor="dynamic-name">{t("proxies.form.name")}</Label>
<Input
id="dynamic-name"
value={dynamicForm.name}
onChange={(e) => {
setDynamicForm({ ...dynamicForm, name: e.target.value });
}}
placeholder="e.g. My Tunnel"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dynamic-url">{t("proxies.dynamic.url")}</Label>
<Input
id="dynamic-url"
value={dynamicForm.url}
onChange={(e) => {
setDynamicForm({ ...dynamicForm, url: e.target.value });
}}
placeholder={t("proxies.dynamic.urlPlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label>{t("proxies.dynamic.format")}</Label>
<Select
value={dynamicForm.format}
onValueChange={(value) => {
setDynamicForm({ ...dynamicForm, format: value });
}}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="json">
{t("proxies.dynamic.formatJson")}
</SelectItem>
<SelectItem value="text">
{t("proxies.dynamic.formatText")}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{dynamicForm.format === "json"
? t("proxies.dynamic.formatJsonHint")
: t("proxies.dynamic.formatTextHint")}
</p>
</div>
<RippleButton
variant="outline"
size="sm"
onClick={handleTestDynamic}
disabled={isSubmitting || isTesting || !dynamicForm.url.trim()}
>
{isTesting
? t("proxies.dynamic.testing")
: t("proxies.dynamic.testUrl")}
</RippleButton>
</>
)}
<div className="grid gap-2">
<Label htmlFor="proxy-password">
{form.proxy_type === "ss"
? t("proxies.form.password")
: `${t("proxies.form.password")} (${t("proxies.form.passwordPlaceholder")})`}
</Label>
<Input
id="proxy-password"
type="password"
value={form.password}
onChange={(e) => {
setForm({ ...form, password: e.target.value });
}}
placeholder={t("proxies.form.passwordPlaceholder")}
disabled={isSubmitting}
/>
</div>
</div>
</div>
<DialogFooter>
@@ -461,7 +283,7 @@ export function ProxyFormDialog({
onClick={handleClose}
disabled={isSubmitting}
>
{t("common.cancel", "Cancel")}
{t("common.buttons.cancel")}
</RippleButton>
<LoadingButton
isLoading={isSubmitting}
@@ -469,14 +469,6 @@ export function ProxyManagementDialog({
</TooltipContent>
</Tooltip>
{proxy.name}
{proxy.dynamic_proxy_url && (
<Badge
variant="outline"
className="text-[10px] px-1 py-0"
>
Dynamic
</Badge>
)}
</div>
</TableCell>
<TableCell>
+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);
+23 -3
View File
@@ -183,7 +183,8 @@
"syncSettings": "Sync Settings",
"assignToGroup": "Assign to Group",
"changeFingerprint": "Change Fingerprint",
"copyCookiesToProfile": "Copy Cookies to Profile"
"copyCookiesToProfile": "Copy Cookies to Profile",
"launchHook": "Launch Hook URL"
},
"synchronizer": {
"launchWithSync": "Launch with Synchronizer",
@@ -229,6 +230,10 @@
"noProxy": "No proxy / VPN",
"noProxiesAvailable": "No proxies or VPNs available. Add one to route this profile's traffic."
},
"launchHook": {
"label": "Launch Hook URL",
"placeholder": "https://example.com/hooks/profile-launch"
},
"version": {
"fetching": "Fetching available versions...",
"fetchError": "Failed to fetch browser versions. Please check your internet connection and try again.",
@@ -273,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",
@@ -318,6 +326,11 @@
"add": "Add Group",
"edit": "Edit Group",
"delete": "Delete Group",
"defaultGroup": "Default",
"defaultGroupNoGroup": "Default (No Group)",
"moveToDefault": "Move profiles to Default group",
"noGroupDescription": "Profiles without a group will appear in the \"Default\" group.",
"assignSuccess": "Successfully assigned {{count}} profile(s) to {{group}}",
"noGroups": "No groups created",
"noGroupsDescription": "Create a group to organize your profiles.",
"form": {
@@ -759,6 +772,7 @@
"browser": "Browser",
"releaseType": "Release Type",
"proxyVpn": "Proxy / VPN",
"launchHook": "Launch Hook",
"group": "Group",
"tags": "Tags",
"note": "Note",
@@ -783,6 +797,12 @@
"noRules": "No bypass rules configured.",
"ruleTypes": "Supports hostnames, IP addresses, and regex patterns."
},
"launchHook": {
"title": "Launch Hook URL",
"label": "Launch Hook URL",
"description": "Donut Browser will POST to this URL whenever the profile is launched.",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
"manageCookies": "Manage Cookies",
"assignExtensionGroup": "Assign Extension Group"
+23 -3
View File
@@ -183,7 +183,8 @@
"syncSettings": "Configuración de Sincronización",
"assignToGroup": "Asignar a Grupo",
"changeFingerprint": "Cambiar Huella Digital",
"copyCookiesToProfile": "Copiar Cookies al Perfil"
"copyCookiesToProfile": "Copiar Cookies al Perfil",
"launchHook": "URL del hook de inicio"
},
"synchronizer": {
"launchWithSync": "Lanzar con Sincronizador",
@@ -229,6 +230,10 @@
"noProxy": "Sin proxy / VPN",
"noProxiesAvailable": "No hay proxies o VPNs disponibles. Agrega uno para enrutar el tráfico de este perfil."
},
"launchHook": {
"label": "URL del hook de inicio",
"placeholder": "https://example.com/hooks/profile-launch"
},
"version": {
"fetching": "Obteniendo versiones disponibles...",
"fetchError": "Error al obtener versiones del navegador. Por favor verifica tu conexión a internet e intenta de nuevo.",
@@ -273,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",
@@ -318,6 +326,11 @@
"add": "Agregar Grupo",
"edit": "Editar Grupo",
"delete": "Eliminar Grupo",
"defaultGroup": "Predeterminado",
"defaultGroupNoGroup": "Predeterminado (Sin Grupo)",
"moveToDefault": "Mover perfiles al grupo Predeterminado",
"noGroupDescription": "Los perfiles sin grupo aparecerán en el grupo \"Predeterminado\".",
"assignSuccess": "Se asignaron {{count}} perfil(es) a {{group}} exitosamente",
"noGroups": "No hay grupos creados",
"noGroupsDescription": "Crea un grupo para organizar tus perfiles.",
"form": {
@@ -759,6 +772,7 @@
"browser": "Navegador",
"releaseType": "Tipo de Versión",
"proxyVpn": "Proxy / VPN",
"launchHook": "Hook de inicio",
"group": "Grupo",
"tags": "Etiquetas",
"note": "Nota",
@@ -783,6 +797,12 @@
"noRules": "No hay reglas de omisión configuradas.",
"ruleTypes": "Soporta nombres de host, direcciones IP y patrones regex."
},
"launchHook": {
"title": "URL del hook de inicio",
"label": "URL del hook de inicio",
"description": "Donut Browser enviará una solicitud POST a esta URL cada vez que se inicie el perfil.",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
"manageCookies": "Administrar Cookies",
"assignExtensionGroup": "Asignar Grupo de Extensiones"
+23 -3
View File
@@ -183,7 +183,8 @@
"syncSettings": "Paramètres de Synchronisation",
"assignToGroup": "Assigner au Groupe",
"changeFingerprint": "Changer l'Empreinte",
"copyCookiesToProfile": "Copier les Cookies vers le Profil"
"copyCookiesToProfile": "Copier les Cookies vers le Profil",
"launchHook": "URL du hook de lancement"
},
"synchronizer": {
"launchWithSync": "Lancer avec le synchroniseur",
@@ -229,6 +230,10 @@
"noProxy": "Pas de proxy / VPN",
"noProxiesAvailable": "Aucun proxy ou VPN disponible. Ajoutez-en un pour router le trafic de ce profil."
},
"launchHook": {
"label": "URL du hook de lancement",
"placeholder": "https://example.com/hooks/profile-launch"
},
"version": {
"fetching": "Récupération des versions disponibles...",
"fetchError": "Échec de la récupération des versions du navigateur. Veuillez vérifier votre connexion Internet et réessayer.",
@@ -273,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",
@@ -318,6 +326,11 @@
"add": "Ajouter un groupe",
"edit": "Modifier le groupe",
"delete": "Supprimer le groupe",
"defaultGroup": "Par défaut",
"defaultGroupNoGroup": "Par défaut (Aucun groupe)",
"moveToDefault": "Déplacer les profils vers le groupe Par défaut",
"noGroupDescription": "Les profils sans groupe apparaîtront dans le groupe « Par défaut ».",
"assignSuccess": "{{count}} profil(s) assigné(s) à {{group}} avec succès",
"noGroups": "Aucun groupe créé",
"noGroupsDescription": "Créez un groupe pour organiser vos profils.",
"form": {
@@ -759,6 +772,7 @@
"browser": "Navigateur",
"releaseType": "Type de Version",
"proxyVpn": "Proxy / VPN",
"launchHook": "Hook de lancement",
"group": "Groupe",
"tags": "Tags",
"note": "Note",
@@ -783,6 +797,12 @@
"noRules": "Aucune règle de contournement configurée.",
"ruleTypes": "Prend en charge les noms d'hôte, les adresses IP et les expressions régulières."
},
"launchHook": {
"title": "URL du hook de lancement",
"label": "URL du hook de lancement",
"description": "Donut Browser enverra une requête POST à cette URL chaque fois que le profil est lancé.",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
"manageCookies": "Gérer les Cookies",
"assignExtensionGroup": "Assigner un Groupe d'Extensions"
+23 -3
View File
@@ -183,7 +183,8 @@
"syncSettings": "同期設定",
"assignToGroup": "グループに割り当て",
"changeFingerprint": "フィンガープリントを変更",
"copyCookiesToProfile": "Cookieをプロファイルにコピー"
"copyCookiesToProfile": "Cookieをプロファイルにコピー",
"launchHook": "起動フックURL"
},
"synchronizer": {
"launchWithSync": "シンクロナイザーで起動",
@@ -229,6 +230,10 @@
"noProxy": "プロキシ / VPNなし",
"noProxiesAvailable": "利用可能なプロキシまたはVPNがありません。このプロファイルのトラフィックをルーティングするために追加してください。"
},
"launchHook": {
"label": "起動フックURL",
"placeholder": "https://example.com/hooks/profile-launch"
},
"version": {
"fetching": "利用可能なバージョンを取得中...",
"fetchError": "ブラウザバージョンの取得に失敗しました。インターネット接続を確認して再試行してください。",
@@ -273,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": "通常",
@@ -318,6 +326,11 @@
"add": "グループを追加",
"edit": "グループを編集",
"delete": "グループを削除",
"defaultGroup": "デフォルト",
"defaultGroupNoGroup": "デフォルト(グループなし)",
"moveToDefault": "プロファイルをデフォルトグループに移動",
"noGroupDescription": "グループに属していないプロファイルは「デフォルト」グループに表示されます。",
"assignSuccess": "{{count}} 件のプロファイルを {{group}} に割り当てました",
"noGroups": "グループがありません",
"noGroupsDescription": "プロファイルを整理するためのグループを作成してください。",
"form": {
@@ -759,6 +772,7 @@
"browser": "ブラウザ",
"releaseType": "リリースタイプ",
"proxyVpn": "プロキシ / VPN",
"launchHook": "起動フック",
"group": "グループ",
"tags": "タグ",
"note": "メモ",
@@ -783,6 +797,12 @@
"noRules": "バイパスルールは設定されていません。",
"ruleTypes": "ホスト名、IPアドレス、正規表現パターンをサポートしています。"
},
"launchHook": {
"title": "起動フックURL",
"label": "起動フックURL",
"description": "プロファイルが起動されるたびに、Donut BrowserはこのURLにPOSTリクエストを送信します。",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
"manageCookies": "Cookieを管理",
"assignExtensionGroup": "拡張機能グループを割り当て"
+23 -3
View File
@@ -183,7 +183,8 @@
"syncSettings": "Configurações de Sincronização",
"assignToGroup": "Atribuir ao Grupo",
"changeFingerprint": "Alterar Impressão Digital",
"copyCookiesToProfile": "Copiar Cookies para o Perfil"
"copyCookiesToProfile": "Copiar Cookies para o Perfil",
"launchHook": "URL do hook de inicialização"
},
"synchronizer": {
"launchWithSync": "Iniciar com Sincronizador",
@@ -229,6 +230,10 @@
"noProxy": "Sem proxy / VPN",
"noProxiesAvailable": "Nenhum proxy ou VPN disponível. Adicione um para rotear o tráfego deste perfil."
},
"launchHook": {
"label": "URL do hook de inicialização",
"placeholder": "https://example.com/hooks/profile-launch"
},
"version": {
"fetching": "Buscando versões disponíveis...",
"fetchError": "Falha ao buscar versões do navegador. Por favor, verifique sua conexão com a internet e tente novamente.",
@@ -273,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",
@@ -318,6 +326,11 @@
"add": "Adicionar Grupo",
"edit": "Editar Grupo",
"delete": "Excluir Grupo",
"defaultGroup": "Padrão",
"defaultGroupNoGroup": "Padrão (Sem Grupo)",
"moveToDefault": "Mover perfis para o grupo Padrão",
"noGroupDescription": "Perfis sem grupo aparecerão no grupo \"Padrão\".",
"assignSuccess": "{{count}} perfil(s) atribuído(s) a {{group}} com sucesso",
"noGroups": "Nenhum grupo criado",
"noGroupsDescription": "Crie um grupo para organizar seus perfis.",
"form": {
@@ -759,6 +772,7 @@
"browser": "Navegador",
"releaseType": "Tipo de Versão",
"proxyVpn": "Proxy / VPN",
"launchHook": "Hook de inicialização",
"group": "Grupo",
"tags": "Tags",
"note": "Nota",
@@ -783,6 +797,12 @@
"noRules": "Nenhuma regra de bypass configurada.",
"ruleTypes": "Suporta nomes de host, endereços IP e padrões regex."
},
"launchHook": {
"title": "URL do hook de inicialização",
"label": "URL do hook de inicialização",
"description": "O Donut Browser enviará uma requisição POST para esta URL sempre que o perfil for iniciado.",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
"manageCookies": "Gerenciar Cookies",
"assignExtensionGroup": "Atribuir Grupo de Extensões"
+23 -3
View File
@@ -183,7 +183,8 @@
"syncSettings": "Настройки синхронизации",
"assignToGroup": "Назначить группе",
"changeFingerprint": "Изменить отпечаток",
"copyCookiesToProfile": "Копировать Cookie в профиль"
"copyCookiesToProfile": "Копировать Cookie в профиль",
"launchHook": "URL хука запуска"
},
"synchronizer": {
"launchWithSync": "Запустить с синхронизатором",
@@ -229,6 +230,10 @@
"noProxy": "Без прокси / VPN",
"noProxiesAvailable": "Нет доступных прокси или VPN. Добавьте один для маршрутизации трафика этого профиля."
},
"launchHook": {
"label": "URL хука запуска",
"placeholder": "https://example.com/hooks/profile-launch"
},
"version": {
"fetching": "Получение доступных версий...",
"fetchError": "Не удалось получить версии браузера. Проверьте интернет-соединение и попробуйте снова.",
@@ -273,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": "Обычный",
@@ -318,6 +326,11 @@
"add": "Добавить группу",
"edit": "Редактировать группу",
"delete": "Удалить группу",
"defaultGroup": "По умолчанию",
"defaultGroupNoGroup": "По умолчанию (Без группы)",
"moveToDefault": "Переместить профили в группу по умолчанию",
"noGroupDescription": "Профили без группы будут отображаться в группе «По умолчанию».",
"assignSuccess": "Успешно назначено {{count}} профиль(ей) в {{group}}",
"noGroups": "Группы не созданы",
"noGroupsDescription": "Создайте группу для организации профилей.",
"form": {
@@ -759,6 +772,7 @@
"browser": "Браузер",
"releaseType": "Тип релиза",
"proxyVpn": "Прокси / VPN",
"launchHook": "Хук запуска",
"group": "Группа",
"tags": "Теги",
"note": "Заметка",
@@ -783,6 +797,12 @@
"noRules": "Правила обхода не настроены.",
"ruleTypes": "Поддерживает имена хостов, IP-адреса и шаблоны регулярных выражений."
},
"launchHook": {
"title": "URL хука запуска",
"label": "URL хука запуска",
"description": "Donut Browser будет отправлять POST-запрос на этот URL при каждом запуске профиля.",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
"manageCookies": "Управление Cookie",
"assignExtensionGroup": "Назначить группу расширений"
+23 -3
View File
@@ -183,7 +183,8 @@
"syncSettings": "同步设置",
"assignToGroup": "分配到组",
"changeFingerprint": "更改指纹",
"copyCookiesToProfile": "复制 Cookies 到配置文件"
"copyCookiesToProfile": "复制 Cookies 到配置文件",
"launchHook": "启动钩子 URL"
},
"synchronizer": {
"launchWithSync": "使用同步器启动",
@@ -229,6 +230,10 @@
"noProxy": "无代理 / VPN",
"noProxiesAvailable": "没有可用的代理或VPN。添加一个来路由此配置文件的流量。"
},
"launchHook": {
"label": "启动钩子 URL",
"placeholder": "https://example.com/hooks/profile-launch"
},
"version": {
"fetching": "正在获取可用版本...",
"fetchError": "获取浏览器版本失败。请检查您的网络连接并重试。",
@@ -273,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": "常规",
@@ -318,6 +326,11 @@
"add": "添加分组",
"edit": "编辑分组",
"delete": "删除分组",
"defaultGroup": "默认",
"defaultGroupNoGroup": "默认(无分组)",
"moveToDefault": "将配置文件移至默认分组",
"noGroupDescription": "未分组的配置文件将显示在「默认」分组中。",
"assignSuccess": "已成功将 {{count}} 个配置文件分配到 {{group}}",
"noGroups": "暂无分组",
"noGroupsDescription": "创建分组来组织您的配置文件。",
"form": {
@@ -759,6 +772,7 @@
"browser": "浏览器",
"releaseType": "发布类型",
"proxyVpn": "代理 / VPN",
"launchHook": "启动钩子",
"group": "分组",
"tags": "标签",
"note": "备注",
@@ -783,6 +797,12 @@
"noRules": "未配置绕过规则。",
"ruleTypes": "支持主机名、IP地址和正则表达式模式。"
},
"launchHook": {
"title": "启动钩子 URL",
"label": "启动钩子 URL",
"description": "每次启动配置文件时,Donut Browser 都会向此 URL 发送 POST 请求。",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
"manageCookies": "管理 Cookie",
"assignExtensionGroup": "分配扩展程序组"
+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;
+2 -3
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;
@@ -18,6 +18,7 @@ export interface BrowserProfile {
version: string;
proxy_id?: string; // Reference to stored proxy
vpn_id?: string; // Reference to stored VPN config
launch_hook?: string;
process_id?: number;
last_launch?: number;
release_type: string; // "stable" or "nightly"
@@ -135,8 +136,6 @@ export interface StoredProxy {
geo_region?: string;
geo_city?: string;
geo_isp?: string;
dynamic_proxy_url?: string;
dynamic_proxy_format?: string;
}
export interface LocationItem {