Compare commits

...

72 Commits

Author SHA1 Message Date
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
zhom 90ef4f3069 chore: linting 2026-04-05 13:33:27 +04:00
andy 378430d7c0 Merge pull request #260 from zhom/dependabot/cargo/src-tauri/rust-dependencies-5e57feb6fc
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 9 updates
2026-04-05 13:00:43 +04:00
andy fc860ccc35 Merge pull request #259 from zhom/dependabot/npm_and_yarn/frontend-dependencies-dea227c4d0
deps(deps): bump the frontend-dependencies group with 19 updates
2026-04-05 13:00:31 +04:00
github-actions[bot] 806aee3e0e chore: update flake.nix for v0.19.0 [skip ci] (#262)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-04 10:38:06 +00:00
github-actions[bot] c6568a126d docs: update CHANGELOG.md and README.md for v0.19.0 [skip ci] (#261)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-04 10:37:59 +00:00
dependabot[bot] 168eac0065 deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [tokio](https://github.com/tokio-rs/tokio) | `1.50.0` | `1.51.0` |
| [bzip2](https://github.com/trifectatechfoundation/bzip2-rs) | `0.5.2` | `0.6.1` |
| [sha1](https://github.com/RustCrypto/hashes) | `0.10.6` | `0.11.0` |
| [tray-icon](https://github.com/tauri-apps/tray-icon) | `0.21.3` | `0.22.0` |
| [muda](https://github.com/tauri-apps/muda) | `0.17.1` | `0.17.2` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.58` | `1.2.59` |
| [semver](https://github.com/dtolnay/semver) | `1.0.27` | `1.0.28` |
| [tokio-macros](https://github.com/tokio-rs/tokio) | `2.6.1` | `2.7.0` |
| [writeable](https://github.com/unicode-org/icu4x) | `0.6.2` | `0.6.3` |


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

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 `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 `tray-icon` from 0.21.3 to 0.22.0
- [Release notes](https://github.com/tauri-apps/tray-icon/releases)
- [Changelog](https://github.com/tauri-apps/tray-icon/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/tray-icon/compare/tray-icon-v0.21.3...tray-icon-v0.22)

Updates `muda` from 0.17.1 to 0.17.2
- [Release notes](https://github.com/tauri-apps/muda/releases)
- [Changelog](https://github.com/tauri-apps/muda/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/muda/compare/muda-v0.17.1...muda-v0.17.2)

Updates `cc` from 1.2.58 to 1.2.59
- [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.58...cc-v1.2.59)

Updates `semver` from 1.0.27 to 1.0.28
- [Release notes](https://github.com/dtolnay/semver/releases)
- [Commits](https://github.com/dtolnay/semver/compare/1.0.27...1.0.28)

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

Updates `writeable` from 0.6.2 to 0.6.3
- [Release notes](https://github.com/unicode-org/icu4x/releases)
- [Changelog](https://github.com/unicode-org/icu4x/blob/main/CHANGELOG.md)
- [Commits](https://github.com/unicode-org/icu4x/compare/ind/ixdtf@0.6.2...ind/ixdtf@0.6.3)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  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: sha1
  dependency-version: 0.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: tray-icon
  dependency-version: 0.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: muda
  dependency-version: 0.17.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.59
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: semver
  dependency-version: 1.0.28
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tokio-macros
  dependency-version: 2.7.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: writeable
  dependency-version: 0.6.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-04 09:45:34 +00:00
dependabot[bot] 9c33d4f7b1 deps(deps): bump the frontend-dependencies group with 19 updates
Bumps the frontend-dependencies group with 19 updates:

| Package | From | To |
| --- | --- | --- |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.9` | `2.4.10` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.5.0` | `25.5.2` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.3` | `6.0.2` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1022.0` | `3.1024.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.1022.0` | `3.1024.0` |
| [@nestjs/common](https://github.com/nestjs/nest/tree/HEAD/packages/common) | `11.1.17` | `11.1.18` |
| [@nestjs/core](https://github.com/nestjs/nest/tree/HEAD/packages/core) | `11.1.17` | `11.1.18` |
| [@nestjs/platform-express](https://github.com/nestjs/nest/tree/HEAD/packages/platform-express) | `11.1.17` | `11.1.18` |
| [@nestjs/testing](https://github.com/nestjs/nest/tree/HEAD/packages/testing) | `11.1.17` | `11.1.18` |
| [@biomejs/cli-darwin-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.9` | `2.4.10` |
| [@biomejs/cli-darwin-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.9` | `2.4.10` |
| [@biomejs/cli-linux-arm64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.9` | `2.4.10` |
| [@biomejs/cli-linux-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.9` | `2.4.10` |
| [@biomejs/cli-linux-x64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.9` | `2.4.10` |
| [@biomejs/cli-linux-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.9` | `2.4.10` |
| [@biomejs/cli-win32-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.9` | `2.4.10` |
| [@biomejs/cli-win32-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.9` | `2.4.10` |
| [file-type](https://github.com/sindresorhus/file-type) | `21.3.2` | `21.3.4` |
| [path-expression-matcher](https://github.com/NaturalIntelligence/path-expression-matcher) | `1.2.0` | `1.2.1` |


Updates `@biomejs/biome` from 2.4.9 to 2.4.10
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.10/packages/@biomejs/biome)

Updates `@types/node` from 25.5.0 to 25.5.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `typescript` from 5.9.3 to 6.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

Updates `@aws-sdk/client-s3` from 3.1022.0 to 3.1024.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1024.0/clients/client-s3)

Updates `@aws-sdk/s3-request-presigner` from 3.1022.0 to 3.1024.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1024.0/packages/s3-request-presigner)

Updates `@nestjs/common` from 11.1.17 to 11.1.18
- [Release notes](https://github.com/nestjs/nest/releases)
- [Commits](https://github.com/nestjs/nest/commits/v11.1.18/packages/common)

Updates `@nestjs/core` from 11.1.17 to 11.1.18
- [Release notes](https://github.com/nestjs/nest/releases)
- [Commits](https://github.com/nestjs/nest/commits/v11.1.18/packages/core)

Updates `@nestjs/platform-express` from 11.1.17 to 11.1.18
- [Release notes](https://github.com/nestjs/nest/releases)
- [Commits](https://github.com/nestjs/nest/commits/v11.1.18/packages/platform-express)

Updates `@nestjs/testing` from 11.1.17 to 11.1.18
- [Release notes](https://github.com/nestjs/nest/releases)
- [Commits](https://github.com/nestjs/nest/commits/v11.1.18/packages/testing)

Updates `@biomejs/cli-darwin-arm64` from 2.4.9 to 2.4.10
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.10/packages/@biomejs/biome)

Updates `@biomejs/cli-darwin-x64` from 2.4.9 to 2.4.10
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.10/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-arm64-musl` from 2.4.9 to 2.4.10
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.10/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-arm64` from 2.4.9 to 2.4.10
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.10/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-x64-musl` from 2.4.9 to 2.4.10
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.10/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-x64` from 2.4.9 to 2.4.10
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.10/packages/@biomejs/biome)

Updates `@biomejs/cli-win32-arm64` from 2.4.9 to 2.4.10
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.10/packages/@biomejs/biome)

Updates `@biomejs/cli-win32-x64` from 2.4.9 to 2.4.10
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.10/packages/@biomejs/biome)

Updates `file-type` from 21.3.2 to 21.3.4
- [Release notes](https://github.com/sindresorhus/file-type/releases)
- [Commits](https://github.com/sindresorhus/file-type/compare/v21.3.2...v21.3.4)

Updates `path-expression-matcher` from 1.2.0 to 1.2.1
- [Commits](https://github.com/NaturalIntelligence/path-expression-matcher/commits/v1.2.1)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@types/node"
  dependency-version: 25.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1024.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.1024.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@nestjs/common"
  dependency-version: 11.1.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@nestjs/core"
  dependency-version: 11.1.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@nestjs/platform-express"
  dependency-version: 11.1.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@nestjs/testing"
  dependency-version: 11.1.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-arm64"
  dependency-version: 2.4.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-x64"
  dependency-version: 2.4.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64-musl"
  dependency-version: 2.4.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64"
  dependency-version: 2.4.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64-musl"
  dependency-version: 2.4.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64"
  dependency-version: 2.4.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-arm64"
  dependency-version: 2.4.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-x64"
  dependency-version: 2.4.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: file-type
  dependency-version: 21.3.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: path-expression-matcher
  dependency-version: 1.2.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-04 09:43:35 +00:00
andy 30f8e3eab2 Merge pull request #258 from zhom/dependabot/github_actions/github-actions-622ddd17b2
ci(deps): bump the github-actions group with 3 updates
2026-04-04 13:13:13 +04:00
dependabot[bot] 02e1f158bd ci(deps): bump the github-actions group with 3 updates
Bumps the github-actions group with 3 updates: [docker/login-action](https://github.com/docker/login-action), [anomalyco/opencode](https://github.com/anomalyco/opencode) and [crate-ci/typos](https://github.com/crate-ci/typos).


Updates `docker/login-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/b45d80f862d83dbcd57f89517bcf500b2ab88fb2...4907a6ddec9925e35a0a9e82d7399ccc52663121)

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

Updates `crate-ci/typos` from 1.44.0 to 1.45.0
- [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/631208b7aac2daa8b707f55e7331f9112b0e062d...02ea592e44b3a53c302f697cddca7641cd051c3d)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.3.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-04 09:04:57 +00:00
zhom 27d108a852 test: simplify 2026-04-04 12:06:05 +04:00
zhom f4301213f6 chore: preserve cargo 2026-04-04 05:08:16 +04:00
zhom d53c939e40 chore: version bump 2026-04-04 04:13:25 +04:00
zhom ff1d63ce41 chore: linting 2026-04-04 04:11:28 +04:00
zhom 214e558a4c refactor: linux auto updates 2026-04-04 03:16:29 +04:00
zhom 48883ddd03 refactor: more robust vpn handling 2026-04-04 03:16:04 +04:00
zhom ac5d975e5b chore: update dependencies 2026-04-02 12:07:31 +04:00
zhom 088f36e38f feat: captcha on email input 2026-04-02 06:19:55 +04:00
zhom e06d2b0aca chore: repo publish workflow 2026-04-02 06:12:42 +04:00
zhom 547fb0bed6 docs: remove codacy badge 2026-04-01 18:15:43 +04:00
zhom c8c2419ff1 chore: copy and backlink 2026-03-31 14:43:52 +04:00
zhom 35723de96a feat: dns block lists 2026-03-31 14:21:31 +04:00
zhom cb8093fbde fix: follow latest MCP spec 2026-03-31 00:48:30 +04:00
zhom 749b439d6d test: serialize 2026-03-29 19:04:12 +04:00
zhom e49b0b30a1 chore: copy correct file 2026-03-29 17:58:18 +04:00
zhom e388e2e85a refactor: don't allow portable build to be set as the default browser 2026-03-29 15:47:53 +04:00
zhom decfdfcfc7 chore: linting 2026-03-29 15:01:26 +04:00
zhom c516999f7a feat: portable build 2026-03-29 14:55:20 +04:00
zhom 1099459dbb fix: wayfern initial connection on macos doesn't timeout 2026-03-29 13:03:17 +04:00
zhom a3514df0d4 refactor: show app version in settings 2026-03-29 13:02:41 +04:00
zhom 0102cb6c06 chore: do not provide possible cause 2026-03-28 23:50:15 +04:00
zhom 612c6610ce chore: linting 2026-03-28 23:31:20 +04:00
zhom ba750a3401 chore: linting 2026-03-28 20:59:00 +04:00
zhom d0e3e15fd3 chore: linting 2026-03-28 20:55:10 +04:00
zhom 248927ae6f chore: linting 2026-03-28 14:05:45 +04:00
zhom 6d71dbc62c Merge pull request #255 from zhom/dependabot/npm_and_yarn/frontend-dependencies-9854c608ec
deps(deps): bump the frontend-dependencies group with 35 updates
2026-03-28 13:53:32 +04:00
dependabot[bot] 3f0029c778 deps(deps): bump the frontend-dependencies group with 35 updates
Bumps the frontend-dependencies group with 35 updates:

| Package | From | To |
| --- | --- | --- |
| [i18next](https://github.com/i18next/i18next) | `25.10.5` | `26.0.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.577.0` | `1.7.0` |
| [react-i18next](https://github.com/i18next/react-i18next) | `16.6.2` | `17.0.0` |
| [recharts](https://github.com/recharts/recharts) | `3.8.0` | `3.8.1` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.3` | `6.0.2` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1015.0` | `3.1019.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.1015.0` | `3.1019.0` |
| [@aws-sdk/core](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/core) | `3.973.24` | `3.973.25` |
| [@aws-sdk/credential-provider-env](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-env) | `3.972.22` | `3.972.23` |
| [@aws-sdk/credential-provider-http](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-http) | `3.972.24` | `3.972.25` |
| [@aws-sdk/credential-provider-ini](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-ini) | `3.972.24` | `3.972.26` |
| [@aws-sdk/credential-provider-login](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-login) | `3.972.24` | `3.972.26` |
| [@aws-sdk/credential-provider-node](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-node) | `3.972.25` | `3.972.27` |
| [@aws-sdk/credential-provider-process](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-process) | `3.972.22` | `3.972.23` |
| [@aws-sdk/credential-provider-sso](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-sso) | `3.972.24` | `3.972.26` |
| [@aws-sdk/credential-provider-web-identity](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-web-identity) | `3.972.24` | `3.972.26` |
| [@aws-sdk/middleware-flexible-checksums](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-flexible-checksums) | `3.974.4` | `3.974.5` |
| [@aws-sdk/middleware-recursion-detection](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-recursion-detection) | `3.972.8` | `3.972.9` |
| [@aws-sdk/middleware-sdk-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-sdk-s3) | `3.972.24` | `3.972.26` |
| [@aws-sdk/middleware-user-agent](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/middleware-user-agent) | `3.972.25` | `3.972.26` |
| [@aws-sdk/nested-clients](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/nested-clients) | `3.996.14` | `3.996.16` |
| [@aws-sdk/region-config-resolver](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/region-config-resolver) | `3.972.9` | `3.972.10` |
| [@aws-sdk/signature-v4-multi-region](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-multi-region) | `3.996.12` | `3.996.14` |
| [@aws-sdk/token-providers](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/token-providers) | `3.1015.0` | `3.1019.0` |
| [@aws-sdk/util-user-agent-node](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/util-user-agent-node) | `3.973.11` | `3.973.12` |
| [@aws-sdk/xml-builder](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/xml-builder) | `3.972.15` | `3.972.16` |
| [@biomejs/cli-darwin-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [@biomejs/cli-darwin-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [@biomejs/cli-linux-arm64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [@biomejs/cli-linux-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [@biomejs/cli-linux-x64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [@biomejs/cli-linux-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [@biomejs/cli-win32-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [@biomejs/cli-win32-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |


Updates `i18next` from 25.10.5 to 26.0.0
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v25.10.5...v26.0.0)

Updates `lucide-react` from 0.577.0 to 1.7.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.7.0/packages/lucide-react)

Updates `react-i18next` from 16.6.2 to 17.0.0
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v16.6.2...v17.0.0)

Updates `recharts` from 3.8.0 to 3.8.1
- [Release notes](https://github.com/recharts/recharts/releases)
- [Changelog](https://github.com/recharts/recharts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/recharts/recharts/compare/v3.8.0...v3.8.1)

Updates `@biomejs/biome` from 2.4.8 to 2.4.9
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.9/packages/@biomejs/biome)

Updates `typescript` from 5.9.3 to 6.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

Updates `@aws-sdk/client-s3` from 3.1015.0 to 3.1019.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1019.0/clients/client-s3)

Updates `@aws-sdk/s3-request-presigner` from 3.1015.0 to 3.1019.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1019.0/packages/s3-request-presigner)

Updates `@aws-sdk/core` from 3.973.24 to 3.973.25
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/core/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/core)

Updates `@aws-sdk/credential-provider-env` from 3.972.22 to 3.972.23
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-env/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-env)

Updates `@aws-sdk/credential-provider-http` from 3.972.24 to 3.972.25
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-http/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-http)

Updates `@aws-sdk/credential-provider-ini` from 3.972.24 to 3.972.26
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-ini/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-ini)

Updates `@aws-sdk/credential-provider-login` from 3.972.24 to 3.972.26
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-login/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-login)

Updates `@aws-sdk/credential-provider-node` from 3.972.25 to 3.972.27
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-node/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-node)

Updates `@aws-sdk/credential-provider-process` from 3.972.22 to 3.972.23
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-process/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-process)

Updates `@aws-sdk/credential-provider-sso` from 3.972.24 to 3.972.26
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-sso/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-sso)

Updates `@aws-sdk/credential-provider-web-identity` from 3.972.24 to 3.972.26
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-web-identity/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-web-identity)

Updates `@aws-sdk/middleware-flexible-checksums` from 3.974.4 to 3.974.5
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/middleware-flexible-checksums/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/middleware-flexible-checksums)

Updates `@aws-sdk/middleware-recursion-detection` from 3.972.8 to 3.972.9
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/middleware-recursion-detection/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/middleware-recursion-detection)

Updates `@aws-sdk/middleware-sdk-s3` from 3.972.24 to 3.972.26
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/middleware-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/middleware-sdk-s3)

Updates `@aws-sdk/middleware-user-agent` from 3.972.25 to 3.972.26
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/middleware-user-agent/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/middleware-user-agent)

Updates `@aws-sdk/nested-clients` from 3.996.14 to 3.996.16
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages/nested-clients)

Updates `@aws-sdk/region-config-resolver` from 3.972.9 to 3.972.10
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/region-config-resolver/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/region-config-resolver)

Updates `@aws-sdk/signature-v4-multi-region` from 3.996.12 to 3.996.14
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages/signature-v4-multi-region)

Updates `@aws-sdk/token-providers` from 3.1015.0 to 3.1019.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/token-providers/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1019.0/packages/token-providers)

Updates `@aws-sdk/util-user-agent-node` from 3.973.11 to 3.973.12
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/util-user-agent-node/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/util-user-agent-node)

Updates `@aws-sdk/xml-builder` from 3.972.15 to 3.972.16
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/xml-builder/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/xml-builder)

Updates `@biomejs/cli-darwin-arm64` from 2.4.8 to 2.4.9
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.9/packages/@biomejs/biome)

Updates `@biomejs/cli-darwin-x64` from 2.4.8 to 2.4.9
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.9/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-arm64-musl` from 2.4.8 to 2.4.9
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.9/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-arm64` from 2.4.8 to 2.4.9
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.9/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-x64-musl` from 2.4.8 to 2.4.9
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.9/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-x64` from 2.4.8 to 2.4.9
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.9/packages/@biomejs/biome)

Updates `@biomejs/cli-win32-arm64` from 2.4.8 to 2.4.9
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.9/packages/@biomejs/biome)

Updates `@biomejs/cli-win32-x64` from 2.4.8 to 2.4.9
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.9/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: lucide-react
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: react-i18next
  dependency-version: 17.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: recharts
  dependency-version: 3.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1019.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.1019.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/core"
  dependency-version: 3.973.25
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-env"
  dependency-version: 3.972.23
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-http"
  dependency-version: 3.972.25
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-ini"
  dependency-version: 3.972.26
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-login"
  dependency-version: 3.972.26
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-node"
  dependency-version: 3.972.27
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-process"
  dependency-version: 3.972.23
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-sso"
  dependency-version: 3.972.26
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/credential-provider-web-identity"
  dependency-version: 3.972.26
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-flexible-checksums"
  dependency-version: 3.974.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-recursion-detection"
  dependency-version: 3.972.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-sdk-s3"
  dependency-version: 3.972.26
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/middleware-user-agent"
  dependency-version: 3.972.26
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/nested-clients"
  dependency-version: 3.996.16
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/region-config-resolver"
  dependency-version: 3.972.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/signature-v4-multi-region"
  dependency-version: 3.996.14
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/token-providers"
  dependency-version: 3.1019.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/util-user-agent-node"
  dependency-version: 3.973.12
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@aws-sdk/xml-builder"
  dependency-version: 3.972.16
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-arm64"
  dependency-version: 2.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-x64"
  dependency-version: 2.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64-musl"
  dependency-version: 2.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64"
  dependency-version: 2.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64-musl"
  dependency-version: 2.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64"
  dependency-version: 2.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-arm64"
  dependency-version: 2.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-x64"
  dependency-version: 2.4.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 09:52:43 +00:00
zhom fff1fe7087 Merge pull request #254 from zhom/dependabot/cargo/src-tauri/rust-dependencies-23f0da4b4d
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 13 updates
2026-03-28 13:48:55 +04:00
dependabot[bot] 1c971c664f deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [bzip2](https://github.com/trifectatechfoundation/bzip2-rs) | `0.5.2` | `0.6.1` |
| [uuid](https://github.com/uuid-rs/uuid) | `1.22.0` | `1.23.0` |
| [sha1](https://github.com/RustCrypto/hashes) | `0.10.6` | `0.11.0` |
| [tao](https://github.com/tauri-apps/tao) | `0.34.8` | `0.35.0` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.57` | `1.2.58` |
| [embed-resource](https://github.com/nabijaczleweli/rust-embed-resource) | `3.0.7` | `3.0.8` |
| libredox | `0.1.14` | `0.1.15` |
| [mio](https://github.com/tokio-rs/mio) | `1.1.1` | `1.2.0` |
| [num-conv](https://github.com/jhpratt/num-conv) | `0.2.0` | `0.2.1` |
| [rust_decimal](https://github.com/paupino/rust-decimal) | `1.40.0` | `1.41.0` |
| [simd-adler32](https://github.com/mcountryman/simd-adler32) | `0.3.8` | `0.3.9` |
| [unicode-segmentation](https://github.com/unicode-rs/unicode-segmentation) | `1.12.0` | `1.13.2` |
| [zune-jpeg](https://github.com/etemesi254/zune-image) | `0.5.14` | `0.5.15` |


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 `uuid` from 1.22.0 to 1.23.0
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.22.0...v1.23.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 `tao` from 0.34.8 to 0.35.0
- [Release notes](https://github.com/tauri-apps/tao/releases)
- [Changelog](https://github.com/tauri-apps/tao/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/tao/compare/tao-v0.34.8...tao-v0.35)

Updates `cc` from 1.2.57 to 1.2.58
- [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.57...cc-v1.2.58)

Updates `embed-resource` from 3.0.7 to 3.0.8
- [Release notes](https://github.com/nabijaczleweli/rust-embed-resource/releases)
- [Commits](https://github.com/nabijaczleweli/rust-embed-resource/compare/v3.0.7...v3.0.8)

Updates `libredox` from 0.1.14 to 0.1.15

Updates `mio` from 1.1.1 to 1.2.0
- [Release notes](https://github.com/tokio-rs/mio/releases)
- [Changelog](https://github.com/tokio-rs/mio/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/mio/compare/v1.1.1...v1.2.0)

Updates `num-conv` from 0.2.0 to 0.2.1
- [Commits](https://github.com/jhpratt/num-conv/compare/v0.2.0...v0.2.1)

Updates `rust_decimal` from 1.40.0 to 1.41.0
- [Release notes](https://github.com/paupino/rust-decimal/releases)
- [Changelog](https://github.com/paupino/rust-decimal/blob/master/CHANGELOG.md)
- [Commits](https://github.com/paupino/rust-decimal/compare/1.40.0...1.41.0)

Updates `simd-adler32` from 0.3.8 to 0.3.9
- [Changelog](https://github.com/mcountryman/simd-adler32/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mcountryman/simd-adler32/commits/v0.3.9)

Updates `unicode-segmentation` from 1.12.0 to 1.13.2
- [Commits](https://github.com/unicode-rs/unicode-segmentation/compare/v1.12.0...v1.13.2)

Updates `zune-jpeg` from 0.5.14 to 0.5.15
- [Release notes](https://github.com/etemesi254/zune-image/releases)
- [Changelog](https://github.com/etemesi254/zune-image/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/etemesi254/zune-image/commits)

---
updated-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: uuid
  dependency-version: 1.23.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: tao
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.58
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: embed-resource
  dependency-version: 3.0.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libredox
  dependency-version: 0.1.15
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: mio
  dependency-version: 1.2.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: num-conv
  dependency-version: 0.2.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rust_decimal
  dependency-version: 1.41.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: simd-adler32
  dependency-version: 0.3.9
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: unicode-segmentation
  dependency-version: 1.13.2
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: zune-jpeg
  dependency-version: 0.5.15
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 09:48:00 +00:00
zhom 0788797e3f Merge pull request #253 from zhom/dependabot/github_actions/github-actions-f059b8c920
ci(deps): bump the github-actions group with 8 updates
2026-03-28 13:22:06 +04:00
dependabot[bot] 8c338515b7 ci(deps): bump the github-actions group with 8 updates
Bumps the github-actions group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4.3.1` | `6.0.2` |
| [google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml](https://github.com/google/osv-scanner-action) | `2.3.3` | `2.3.5` |
| [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata) | `2.5.0` | `3.0.0` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3.12.0` | `4.0.0` |
| [docker/login-action](https://github.com/docker/login-action) | `3.7.0` | `4.0.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6.19.2` | `7.0.0` |
| [anomalyco/opencode](https://github.com/anomalyco/opencode) | `1.2.27` | `1.3.3` |
| [google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml](https://github.com/google/osv-scanner-action) | `2.3.3` | `2.3.5` |


Updates `actions/checkout` from 4.3.1 to 6.0.2
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v4.3.1...v6.0.2)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml` from 2.3.3 to 2.3.5
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/c5996e0193a3df57d695c1b8a1dec2a4c62e8730...c51854704019a247608d928f370c98740469d4b5)

Updates `dependabot/fetch-metadata` from 2.5.0 to 3.0.0
- [Release notes](https://github.com/dependabot/fetch-metadata/releases)
- [Commits](https://github.com/dependabot/fetch-metadata/compare/21025c705c08248db411dc16f3619e6b5f9ea21a...ffa630c65fa7e0ecfa0625b5ceda64399aea1b36)

Updates `docker/setup-buildx-action` from 3.12.0 to 4.0.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/8d2750c68a42422c14e847fe6c8ac0403b4cbd6f...4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd)

Updates `docker/login-action` from 3.7.0 to 4.0.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/c94ce9fb468520275223c153574b00df6fe4bcc9...b45d80f862d83dbcd57f89517bcf500b2ab88fb2)

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

Updates `anomalyco/opencode` from 1.2.27 to 1.3.3
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/4ee426ba549131c4903a71dfb6259200467aca81...54443bfb7e090ec3130dc972e689a3e5cc55a7f9)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml` from 2.3.3 to 2.3.5
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/c5996e0193a3df57d695c1b8a1dec2a4c62e8730...c51854704019a247608d928f370c98740469d4b5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml
  dependency-version: 2.3.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: dependabot/fetch-metadata
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/login-action
  dependency-version: 4.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.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.3.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml
  dependency-version: 2.3.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 09:05:04 +00:00
zhom a8c179fca7 docs: agents 2026-03-28 01:41:01 +04:00
zhom d0f436ce2d chore: commit doc changes directly and pretty discord notifications 2026-03-28 01:41:01 +04:00
zhom 4019701186 Merge pull request #252 from zhom/contributors-readme-action-7fGCZTC5jp
docs(contributor): contributors readme action update
2026-03-27 20:11:15 +04:00
github-actions[bot] 53f85abe24 docs(contributor): contrib-readme-action has updated readme 2026-03-27 16:08:11 +00:00
zhom 2aafb4c7a4 Merge pull request #249 from yb403/fix/sync-loop-circular-dependency
This fix prevents the file watcher from triggering a new sync when th…
2026-03-27 20:07:59 +04:00
zhom 00d5c655dc Merge pull request #251 from zhom/chore/update-flake-0.18.1
chore: update flake.nix for v0.18.1
2026-03-25 03:39:31 +04:00
zhom b12a704d9f Merge pull request #250 from zhom/docs/release-0.18.1
docs: release notes for v0.18.1
2026-03-25 03:39:20 +04:00
github-actions[bot] 0e134fd145 chore: update flake.nix for v0.18.1 [skip ci] 2026-03-24 23:08:33 +00:00
github-actions[bot] adcdc91de2 docs: update CHANGELOG.md and README.md for v0.18.1 [skip ci] 2026-03-24 23:08:31 +00:00
yb 880014d4c4 chore: fix linting and formatting 2026-03-24 22:50:28 +01:00
zhom 71f367f0ae docs: cleanup 2026-03-25 01:36:43 +04:00
yb 001a292185 This fix prevents the file watcher from triggering a new sync when the client updates the last_sync timestamp in metadata.json. 2026-03-24 13:20:28 +01:00
153 changed files with 9481 additions and 4161 deletions
+5
View File
@@ -197,6 +197,7 @@ These are frequently overlooked issues that make UI look unprofessional:
Before delivering UI code, verify these items:
### Visual Quality
- [ ] No emojis used as icons (use SVG instead)
- [ ] All icons from consistent icon set (Heroicons/Lucide)
- [ ] Brand logos are correct (verified from Simple Icons)
@@ -204,24 +205,28 @@ Before delivering UI code, verify these items:
- [ ] Use theme colors directly (bg-primary) not var() wrapper
### Interaction
- [ ] All clickable elements have `cursor-pointer`
- [ ] Hover states provide clear visual feedback
- [ ] Transitions are smooth (150-300ms)
- [ ] Focus states visible for keyboard navigation
### Light/Dark Mode
- [ ] Light mode text has sufficient contrast (4.5:1 minimum)
- [ ] Glass/transparent elements visible in light mode
- [ ] Borders visible in both modes
- [ ] Test both modes before delivery
### Layout
- [ ] Floating elements have proper spacing from edges
- [ ] No content hidden behind fixed navbars
- [ ] Responsive at 320px, 768px, 1024px, 1440px
- [ ] No horizontal scroll on mobile
### Accessibility
- [ ] All images have alt text
- [ ] Form inputs have labels
- [ ] Color is not the only indicator
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Contribute List
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
env:
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
security-scan:
name: Security Vulnerability Scan
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
with:
scan-args: |-
-r
@@ -69,7 +69,7 @@ jobs:
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a #v2.5.0
uses: dependabot/fetch-metadata@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36 #v3.0.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Enable auto-merge for minor and patch updates
+4 -4
View File
@@ -30,13 +30,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 #v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f #v3
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
- name: Log in to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 #v3
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 #v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -62,7 +62,7 @@ jobs:
echo "Tags: ${TAGS}"
- name: Build and push Docker image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 #v6
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 #v7.0.0
with:
context: .
file: ./donut-sync/Dockerfile
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Install Nix
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31
+8 -8
View File
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Check if first-time contributor
id: check-first-time
@@ -30,9 +30,9 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
run: |
ISSUE_COUNT=$(gh api "/repos/${{ github.repository }}/issues" \
--jq "map(select(.user.login == \"$ISSUE_AUTHOR\" and .number != ${{ github.event.issue.number }})) | length" \
--paginate || echo "0")
ISSUE_COUNT=$(gh api "/repos/${{ github.repository }}/issues?state=all&creator=$ISSUE_AUTHOR&per_page=100" \
--jq "[.[] | select(.number != ${{ github.event.issue.number }}) ] | length" \
|| echo "0")
if [ "$ISSUE_COUNT" = "0" ]; then
echo "is_first_time=true" >> $GITHUB_OUTPUT
@@ -131,7 +131,7 @@ jobs:
messages: [
{
role: "system",
content: ("You are a triage bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nYou have access to relevant source files for context. Use them to give specific, actionable advice.\n\nAnalyze the issue and produce a single comment. Format:\n\n1. One sentence acknowledging the issue.\n2. **Possible cause** - Based on the source code, briefly explain what might be going wrong and which files are involved. Be specific (mention file names, function names, line ranges if possible).\n3. **Action items** - What specific info is missing or what the user should try. Only include items that are actually missing.\n - For bug reports: if logs are needed, tell the user EXACTLY how to get them:\n - macOS app logs: `~/Library/Logs/Donut Browser/`\n - Linux app logs: `~/.local/share/DonutBrowser/logs/`\n - Windows app logs: `%APPDATA%\\DonutBrowser\\logs\\`\n - Sync server logs: `docker logs <container>` or check the server console\n - Provide a ready-to-run shell command when possible.\n - For self-hosted sync issues: check if the user is using the latest Docker image (`docker pull donutbrowser/donut-sync:latest`).\n4. Suggest a label: `Label: bug` or `Label: enhancement` on its own line.\n\nRules:\n- Be brief but specific. Reference actual code when possible.\n- If the issue already has everything needed, just acknowledge it and point to the likely cause.\n- Never exceed 15 lines.")
content: ("You are a triage bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nYou have access to relevant source files for context.\n\nAnalyze the issue and produce a single comment. Your job is to collect missing information needed to diagnose the issue, NOT to guess the cause.\n\nFormat:\n\n1. One sentence acknowledging the issue.\n2. **Missing information** - Ask specific questions about what is missing from the report. Focus on reproducing the issue. Do NOT speculate about root causes or mention internal code/files — you will almost certainly be wrong without logs. Instead, ask for:\n - Exact steps to reproduce (if not provided)\n - Expected vs actual behavior (if unclear)\n - Error messages or screenshots (if not provided)\n - OS and app version (if not provided)\n - For bug reports: if logs are needed, tell the user EXACTLY how to get them:\n - macOS app logs: `~/Library/Logs/Donut Browser/`\n - Linux app logs: `~/.local/share/DonutBrowser/logs/`\n - Windows app logs: `%APPDATA%\\DonutBrowser\\logs\\`\n - Sync server logs: `docker logs <container>` or check the server console\n - Provide a ready-to-run shell command when possible.\n - For self-hosted sync issues: check if the user is using the latest Docker image (`docker pull donutbrowser/donut-sync:latest`).\n - Only ask for information that is actually missing. If the issue is already detailed, just acknowledge it.\n3. Suggest a label: `Label: bug` or `Label: enhancement` on its own line.\n\nRules:\n- Do NOT include a \"Possible cause\" section. Do not speculate about what code might be causing the issue.\n- Be brief and focused on collecting actionable information from the reporter.\n- If the issue already has everything needed (steps to reproduce, logs, version, OS), just acknowledge it.\n- Never exceed 15 lines.")
},
{
role: "user",
@@ -181,7 +181,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Check if first-time contributor
id: check-first-time
@@ -324,10 +324,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Run opencode
uses: anomalyco/opencode/github@4ee426ba549131c4903a71dfb6259200467aca81 #v1.2.27
uses: anomalyco/opencode/github@6314f09c14fdd6a3ab8bedc4f7b7182647551d12 #v1.3.13
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
+4 -8
View File
@@ -41,7 +41,7 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
@@ -67,7 +67,7 @@ jobs:
if: matrix.os == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev openvpn
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
@@ -113,12 +113,8 @@ jobs:
run: cargo clippy --all-targets --all-features -- -D warnings -D clippy::all
working-directory: src-tauri
- name: Run Rust unit tests
run: cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration
working-directory: src-tauri
- name: Run Rust sync e2e tests
run: node scripts/sync-test-harness.mjs
- name: Run test suite
run: pnpm test
- name: Run cargo audit security check
run: cargo audit
+2 -2
View File
@@ -46,7 +46,7 @@ jobs:
scan-scheduled:
name: Scheduled Security Scan
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
with:
scan-args: |-
-r
@@ -58,7 +58,7 @@ jobs:
scan-pr:
name: PR Security Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
with:
scan-args: |-
-r
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
security-scan:
name: Security Vulnerability Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
with:
scan-args: |-
-r
+211
View File
@@ -0,0 +1,211 @@
name: Publish Linux Repos
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g. v0.18.1). Leave empty for latest."
required: false
type: string
workflow_run:
workflows: ["Release"]
types:
- completed
permissions:
contents: read
jobs:
publish-repos:
if: >
github.repository == 'zhom/donutbrowser' &&
(github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
steps:
- name: Determine release tag
id: tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_TAG: ${{ inputs.tag }}
run: |
if [[ -n "${INPUT_TAG:-}" ]]; then
echo "tag=${INPUT_TAG}" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
# The Release workflow is triggered by a tag push (v*),
# so head_branch is the tag name
echo "tag=${{ github.event.workflow_run.head_branch }}" >> "$GITHUB_OUTPUT"
else
TAG=$(gh release view --repo "${{ github.repository }}" --json tagName -q .tagName)
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
- name: Download packages from GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
mkdir -p /tmp/packages
gh release download "$TAG" \
--repo "${{ github.repository }}" \
--pattern "*.deb" \
--dir /tmp/packages
gh release download "$TAG" \
--repo "${{ github.repository }}" \
--pattern "*.rpm" \
--dir /tmp/packages
echo "Downloaded packages:"
ls -lh /tmp/packages/
- name: Build DEB repository
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
DEB_DIR="/tmp/repo/deb"
mkdir -p "$DEB_DIR/pool/main"
mkdir -p "$DEB_DIR/dists/stable/main/binary-amd64"
mkdir -p "$DEB_DIR/dists/stable/main/binary-arm64"
# Sync existing pool from R2 (incremental)
aws s3 sync "s3://${R2_BUCKET}/deb/pool" "$DEB_DIR/pool" \
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || true
# Copy new .deb files into pool
cp /tmp/packages/*.deb "$DEB_DIR/pool/main/" 2>/dev/null || true
# Generate Packages and Packages.gz for each arch
for arch in amd64 arm64; do
BINARY_DIR="$DEB_DIR/dists/stable/main/binary-${arch}"
(cd "$DEB_DIR" && dpkg-scanpackages --arch "$arch" pool/main) \
> "$BINARY_DIR/Packages"
gzip -9c "$BINARY_DIR/Packages" > "$BINARY_DIR/Packages.gz"
echo " $arch: $(grep -c '^Package:' "$BINARY_DIR/Packages" 2>/dev/null || echo 0) package(s)"
done
# Generate Release file
{
echo "Origin: Donut Browser"
echo "Label: Donut Browser"
echo "Suite: stable"
echo "Codename: stable"
echo "Architectures: amd64 arm64"
echo "Components: main"
echo "Date: $(date -u '+%a, %d %b %Y %H:%M:%S UTC')"
echo "MD5Sum:"
for arch in amd64 arm64; do
for file in "main/binary-${arch}/Packages" "main/binary-${arch}/Packages.gz"; do
filepath="$DEB_DIR/dists/stable/$file"
if [[ -f "$filepath" ]]; then
size=$(wc -c < "$filepath")
md5=$(md5sum "$filepath" | awk '{print $1}')
printf " %s %8d %s\n" "$md5" "$size" "$file"
fi
done
done
echo "SHA256:"
for arch in amd64 arm64; do
for file in "main/binary-${arch}/Packages" "main/binary-${arch}/Packages.gz"; do
filepath="$DEB_DIR/dists/stable/$file"
if [[ -f "$filepath" ]]; then
size=$(wc -c < "$filepath")
sha256=$(sha256sum "$filepath" | awk '{print $1}')
printf " %s %8d %s\n" "$sha256" "$size" "$file"
fi
done
done
} > "$DEB_DIR/dists/stable/Release"
echo "DEB Release file created."
- name: Build RPM repository
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
RPM_DIR="/tmp/repo/rpm"
mkdir -p "$RPM_DIR/x86_64"
mkdir -p "$RPM_DIR/aarch64"
# Sync existing RPMs from R2 (incremental)
aws s3 sync "s3://${R2_BUCKET}/rpm/x86_64" "$RPM_DIR/x86_64" \
--endpoint-url "$R2_ENDPOINT" --exclude "repodata/*" 2>/dev/null || true
aws s3 sync "s3://${R2_BUCKET}/rpm/aarch64" "$RPM_DIR/aarch64" \
--endpoint-url "$R2_ENDPOINT" --exclude "repodata/*" 2>/dev/null || true
# Copy new .rpm files into arch directories
for rpm in /tmp/packages/*.rpm; do
[[ -f "$rpm" ]] || continue
filename=$(basename "$rpm")
if [[ "$filename" == *x86_64* ]]; then
cp "$rpm" "$RPM_DIR/x86_64/"
elif [[ "$filename" == *aarch64* ]]; then
cp "$rpm" "$RPM_DIR/aarch64/"
fi
done
# Generate repodata
createrepo_c --update "$RPM_DIR"
echo "RPM repodata created."
- name: Upload to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
echo "Uploading DEB repository..."
aws s3 sync /tmp/repo/deb/dists "s3://${R2_BUCKET}/deb/dists" \
--endpoint-url "$R2_ENDPOINT" --delete
aws s3 sync /tmp/repo/deb/pool "s3://${R2_BUCKET}/deb/pool" \
--endpoint-url "$R2_ENDPOINT"
echo "Uploading RPM repository..."
aws s3 sync /tmp/repo/rpm "s3://${R2_BUCKET}/rpm" \
--endpoint-url "$R2_ENDPOINT"
- name: Verify upload
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
echo "Published repos for $TAG"
echo ""
echo "DEB dists/stable/:"
aws s3 ls "s3://${R2_BUCKET}/deb/dists/stable/" \
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)"
echo "DEB pool/main/:"
aws s3 ls "s3://${R2_BUCKET}/deb/pool/main/" \
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)"
echo "RPM repodata/:"
aws s3 ls "s3://${R2_BUCKET}/rpm/repodata/" \
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)"
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
fetch-depth: 0
+123 -19
View File
@@ -20,7 +20,7 @@ jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
with:
scan-args: |-
-r
@@ -105,7 +105,7 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
@@ -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 }}
@@ -225,6 +235,44 @@ jobs:
prerelease: false
args: ${{ matrix.args }}
- name: Create portable Windows ZIP
if: matrix.platform == 'windows-latest'
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#v}"
PORTABLE_DIR="Donut-Portable"
mkdir -p "$PORTABLE_DIR"
# Copy main executable
cp "src-tauri/target/${{ matrix.target }}/release/donutbrowser.exe" "$PORTABLE_DIR/Donut.exe"
# Copy sidecar binaries
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
# Copy WebView2Loader if present
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/"
fi
# Create .portable marker
touch "$PORTABLE_DIR/.portable"
# Create ZIP
7z a "Donut_${VERSION}_x64-portable.zip" "$PORTABLE_DIR"
- name: Upload portable ZIP to release
if: matrix.platform == 'windows-latest'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#v}"
gh release upload "$TAG" "Donut_${VERSION}_x64-portable.zip" --clobber
- name: Clean up Apple certificate
if: matrix.platform == 'macos-latest' && always()
run: |
@@ -239,7 +287,7 @@ jobs:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: main
fetch-depth: 0
@@ -346,7 +394,7 @@ jobs:
### Windows
[Download Windows Installer (x64)](${BASE}/Donut_${VERSION}_x64-setup.exe)
[Download Windows Installer (x64)](${BASE}/Donut_${VERSION}_x64-setup.exe) · [Portable (x64)](${BASE}/Donut_${VERSION}_x64-portable.zip)
### Linux
@@ -390,7 +438,7 @@ jobs:
--body "Automated update of CHANGELOG.md and README.md download links for ${TAG}." \
--base main \
--head "$BRANCH"
gh pr merge "$BRANCH" --auto --squash
gh pr merge "$BRANCH" --squash --admin
fi
- name: Update release notes
@@ -402,26 +450,82 @@ jobs:
notify-discord:
if: github.repository == 'zhom/donutbrowser'
needs: [release]
needs: [release, changelog]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: main
fetch-depth: 0
- name: Generate changelog summary
env:
TAG: ${{ github.ref_name }}
run: |
PREV_TAG=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -v "^${TAG}$" \
| head -n 1)
if [ -z "$PREV_TAG" ]; then
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
fi
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
CHANGES=""
while IFS= read -r msg; do
[ -z "$msg" ] && continue
case "$msg" in
feat\(*\):*|feat:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
fix\(*\):*|fix:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
refactor\(*\):*|refactor:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
perf\(*\):*|perf:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
esac
done < <(git log --pretty=format:"%s" "${PREV_TAG}..${TAG}" --no-merges)
# Truncate to fit Discord embed (max 4096 chars)
if [ ${#CHANGES} -gt 3900 ]; then
CHANGES="${CHANGES:0:3900}\n..."
fi
if [ -z "$CHANGES" ]; then
CHANGES="See the full changelog on GitHub."
fi
printf '%s' "$CHANGES" > /tmp/discord-changes.txt
- name: Send Discord notification
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_STABLE_WEBHOOK_URL }}
TAG: ${{ github.ref_name }}
run: |
VERSION="${GITHUB_REF_NAME}"
VERSION="${TAG}"
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${VERSION}"
CHANGES=$(cat /tmp/discord-changes.txt)
curl -fsSL -H "Content-Type: application/json" \
-d "{
\"embeds\": [{
\"title\": \"Donut Browser ${VERSION} Released\",
\"url\": \"${RELEASE_URL}\",
\"description\": \"A new stable release of Donut Browser is available.\",
\"color\": 5814783
# Build JSON with jq to handle escaping
PAYLOAD=$(jq -n \
--arg title "Donut Browser ${VERSION} Released" \
--arg url "$RELEASE_URL" \
--arg changes "$CHANGES" \
--arg dl_mac_arm "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_aarch64.dmg" \
--arg dl_mac_intel "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_x64.dmg" \
--arg dl_win "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_x64-setup.exe" \
--arg dl_linux "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_amd64.AppImage" \
'{
embeds: [{
title: $title,
url: $url,
description: $changes,
color: 5814783,
fields: [
{ name: "Download", value: ("[macOS (Apple Silicon)](" + $dl_mac_arm + ") · [macOS (Intel)](" + $dl_mac_intel + ")\n[Windows x64](" + $dl_win + ") · [Linux x64](" + $dl_linux + ")"), inline: false }
],
footer: { text: "donutbrowser.com" }
}]
}" \
"$DISCORD_WEBHOOK_URL"
}')
curl -fsSL -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL"
deploy-website:
if: github.repository == 'zhom/donutbrowser'
@@ -441,13 +545,13 @@ jobs:
update-flake:
if: github.repository == 'zhom/donutbrowser'
needs: [release]
needs: [release, changelog]
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: main
@@ -516,4 +620,4 @@ jobs:
--body "Automated update of flake.nix with new AppImage hashes for v${VERSION}." \
--base main \
--head "$BRANCH"
gh pr merge "$BRANCH" --auto --squash
gh pr merge "$BRANCH" --squash --admin
+57 -12
View File
@@ -19,7 +19,7 @@ jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
with:
scan-args: |-
-r
@@ -104,7 +104,7 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
@@ -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 }}"
@@ -235,6 +242,34 @@ jobs:
prerelease: true
args: ${{ matrix.args }}
- name: Create portable Windows ZIP
if: matrix.platform == 'windows-latest'
shell: bash
run: |
PORTABLE_DIR="Donut-Portable"
mkdir -p "$PORTABLE_DIR"
cp "src-tauri/target/${{ matrix.target }}/release/donutbrowser.exe" "$PORTABLE_DIR/Donut.exe"
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/"
fi
touch "$PORTABLE_DIR/.portable"
7z a "Donut_x64-portable.zip" "$PORTABLE_DIR"
- name: Upload portable ZIP to release
if: matrix.platform == 'windows-latest'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NIGHTLY_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
run: |
gh release upload "$NIGHTLY_TAG" "Donut_x64-portable.zip" --clobber
- name: Clean up Apple certificate
if: matrix.platform == 'macos-latest' && always()
run: |
@@ -248,7 +283,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Generate nightly tag
id: tag
@@ -364,14 +399,24 @@ jobs:
run: |
COMMIT_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7)
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/nightly"
COMMIT_URL="https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}"
curl -fsSL -H "Content-Type: application/json" \
-d "{
\"embeds\": [{
\"title\": \"Donut Browser Nightly Updated\",
\"url\": \"${RELEASE_URL}\",
\"description\": \"A new nightly build is available (${COMMIT_SHORT}).\",
\"color\": 16752128
PAYLOAD=$(jq -n \
--arg title "Donut Browser Nightly (${COMMIT_SHORT})" \
--arg url "$RELEASE_URL" \
--arg commit_url "$COMMIT_URL" \
--arg commit_short "$COMMIT_SHORT" \
'{
embeds: [{
title: $title,
url: $url,
color: 16752128,
fields: [
{ name: "Commit", value: ("[" + $commit_short + "](" + $commit_url + ")"), inline: true },
{ name: "Download", value: ("[Nightly Release](" + $url + ")"), inline: true }
],
footer: { text: "donutbrowser.com" }
}]
}" \
"$DISCORD_WEBHOOK_URL"
}')
curl -fsSL -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL"
+2 -2
View File
@@ -21,6 +21,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions Repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Spell Check Repo
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d #v1.44.0
uses: crate-ci/typos@02ea592e44b3a53c302f697cddca7641cd051c3d #v1.45.0
+4 -4
View File
@@ -32,7 +32,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v6.0.2
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
@@ -73,7 +73,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v6.0.2
- name: Start MinIO
run: |
@@ -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
@@ -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
+23 -1
View File
@@ -1,6 +1,6 @@
# Project Guidelines
> **IMPORTANT**: CLAUDE.md and AGENTS.md must always be identical. If you update one, update the other.
> **NOTE**: CLAUDE.md is a symlink to AGENTS.md — editing either file updates both.
> After significant changes (new modules, renamed files, new directories), re-evaluate the Repository Structure below and update it if needed.
## Repository Structure
@@ -83,5 +83,27 @@ donutbrowser/
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
## Publishing Linux Repositories
The `scripts/publish-repo.sh` script publishes DEB and RPM packages to Cloudflare R2 (served at `repo.donutbrowser.com`). It requires Linux tools, so run it in Docker on macOS:
```bash
docker run --rm -v "$(pwd):/work" -w /work --env-file .env -e GH_TOKEN="$(gh auth token)" \
ubuntu:24.04 bash -c '
export DEBIAN_FRONTEND=noninteractive &&
apt-get update -qq > /dev/null 2>&1 &&
apt-get install -y -qq dpkg-dev createrepo-c gzip curl python3-pip > /dev/null 2>&1 &&
pip3 install --break-system-packages awscli > /dev/null 2>&1 &&
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg 2>/dev/null &&
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list &&
apt-get update -qq > /dev/null 2>&1 && apt-get install -y -qq gh > /dev/null 2>&1 &&
bash scripts/publish-repo.sh v0.18.1'
```
The `.github/workflows/publish-repos.yml` workflow runs automatically after stable releases and can also be triggered manually via `gh workflow run publish-repos.yml -f tag=v0.18.1`.
Required env vars / secrets: `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ENDPOINT_URL`, `R2_BUCKET_NAME`.
## Proprietary Changes
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
+127
View File
@@ -0,0 +1,127 @@
# Changelog
## 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
- captcha on email input
- dns block lists
- portable build
### Bug Fixes
- follow latest MCP spec
- wayfern initial connection on macos doesn't timeout
### Refactoring
- linux auto updates
- more robust vpn handling
- don't allow portable build to be set as the default browser
- show app version in settings
### Documentation
- remove codacy badge
- agents
- contrib-readme-action has updated readme
- update CHANGELOG.md and README.md for v0.18.1 [skip ci]
- cleanup
### Maintenance
- test: simplify
- chore: preserve cargo
- chore: version bump
- chore: linting
- chore: update dependencies
- chore: repo publish workflow
- chore: copy and backlink
- test: serialize
- chore: copy correct file
- chore: linting
- chore: do not provide possible cause
- chore: linting
- chore: linting
- chore: linting
- chore: linting
- ci(deps): bump the github-actions group with 8 updates
- chore: commit doc changes directly and pretty discord notifications
- chore: update flake.nix for v0.18.1 [skip ci]
- chore: fix linting and formatting
### Other
- deps(deps): bump the frontend-dependencies group with 35 updates
- deps(rust)(deps): bump the rust-dependencies group
## v0.18.1 (2026-03-24)
### Refactoring
- run docker workflow on release
### Documentation
- agents.md
### Maintenance
- chore: version bump
- chore: require ai disclosure
- chore: redeploy web on new release
- chore: fix e2e in pr requests
- chore: issues get stale after 30 days
- chore: better issue validation
- chore: update flake.nix for v0.18.0 [skip ci] (#247)
-87
View File
@@ -1,87 +0,0 @@
# Project Guidelines
> **IMPORTANT**: CLAUDE.md and AGENTS.md must always be identical. If you update one, update the other.
> After significant changes (new modules, renamed files, new directories), re-evaluate the Repository Structure below and update it if needed.
## Repository Structure
```
donutbrowser/
├── src/ # Next.js frontend
│ ├── app/ # App router (page.tsx, layout.tsx)
│ ├── components/ # 50+ React components (dialogs, tables, UI)
│ ├── hooks/ # Event-driven React hooks
│ ├── i18n/locales/ # Translations (en, es, fr, ja, pt, ru, zh)
│ ├── lib/ # Utilities (themes, toast, browser-utils)
│ └── types.ts # Shared TypeScript interfaces
├── src-tauri/ # Rust backend (Tauri)
│ ├── src/
│ │ ├── lib.rs # Tauri command registration (100+ commands)
│ │ ├── browser_runner.rs # Profile launch/kill orchestration
│ │ ├── browser.rs # Browser trait & launch logic
│ │ ├── profile/ # Profile CRUD (manager.rs, types.rs)
│ │ ├── proxy_manager.rs # Proxy lifecycle & connection testing
│ │ ├── proxy_server.rs # Local proxy binary (donut-proxy)
│ │ ├── proxy_storage.rs # Proxy config persistence (JSON files)
│ │ ├── api_server.rs # REST API (utoipa + axum)
│ │ ├── mcp_server.rs # MCP protocol server
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
│ │ ├── vpn/ # WireGuard & OpenVPN tunnels
│ │ ├── camoufox/ # Camoufox fingerprint engine (Bayesian network)
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
│ │ ├── camoufox_manager.rs # Camoufox (Firefox) browser management
│ │ ├── downloader.rs # Browser binary downloader
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
│ │ ├── settings_manager.rs # App settings persistence
│ │ ├── cookie_manager.rs # Cookie import/export
│ │ ├── extension_manager.rs # Browser extension management
│ │ ├── group_manager.rs # Profile group management
│ │ ├── synchronizer.rs # Real-time profile synchronizer
│ │ ├── daemon/ # Background daemon + tray icon (currently disabled)
│ │ └── cloud_auth.rs # Cloud authentication
│ ├── tests/ # Integration tests
│ └── Cargo.toml # Rust dependencies
├── donut-sync/ # NestJS sync server (self-hostable)
│ └── src/ # Controllers, services, auth, S3 sync
├── docs/ # Documentation (self-hosting guide)
├── flake.nix # Nix development environment
└── .github/workflows/ # CI/CD pipelines
```
## Testing and Quality
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
- Always run this command before finishing a task to ensure the application isn't broken
- `pnpm lint` includes spellcheck via [typos](https://github.com/crate-ci/typos). False positives can be allowlisted in `_typos.toml`
## Code Quality
- Don't leave comments that don't add value
- Don't duplicate code unless there's a very good reason; keep the same logic in one place
- Anytime you make changes that affect copy or add new text, it has to be reflected in all translation files
## Singletons
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
## UI Theming
- Never use hardcoded Tailwind color classes (e.g., `text-red-500`, `bg-green-600`, `border-yellow-400`). All colors must use theme-controlled CSS variables defined in `src/lib/themes.ts`
- Available semantic color classes:
- `background`, `foreground` — page/container background and text
- `card`, `card-foreground` — card surfaces
- `popover`, `popover-foreground` — dropdown/popover surfaces
- `primary`, `primary-foreground` — primary actions
- `secondary`, `secondary-foreground` — secondary actions
- `muted`, `muted-foreground` — muted/disabled elements
- `accent`, `accent-foreground` — accent highlights
- `destructive`, `destructive-foreground` — errors, danger, delete actions
- `success`, `success-foreground` — success states, valid indicators
- `warning`, `warning-foreground` — warnings, caution messages
- `border` — borders
- `chart-1` through `chart-5` — data visualization
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
## Proprietary Changes
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+2
View File
@@ -27,6 +27,7 @@ Or enter the dev shell: `nix develop`
### Manual Setup
Requirements:
- Node.js (see `.node-version`)
- pnpm
- Rust + Cargo (latest stable)
@@ -47,6 +48,7 @@ pnpm format && pnpm lint && pnpm test
```
This runs:
- **Biome** — JS/TS linting and formatting
- **Clippy + rustfmt** — Rust linting and formatting
- **typos** — Spellcheck (allowlist in `_typos.toml`)
+14 -13
View File
@@ -16,11 +16,8 @@
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/blob/main/LICENSE" target="_blank">
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
</a>
<a href="https://app.codacy.com/gh/zhom/donutbrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade">
<img src="https://app.codacy.com/project/badge/Grade/b9c9beafc92d4bc8bc7c5b42c6c4ba81"/>
</a>
<a href="https://app.fossa.com/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser?ref=badge_shield&issueType=security" alt="FOSSA Status">
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security"/>
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security" alt="FOSSA Security Status"/>
</a>
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/network/members" target="_blank">
<img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
@@ -45,18 +42,16 @@
- **Default browser** — set Donut as your default browser and choose which profile opens each link
- **Cloud sync** — sync profiles, proxies, and groups across devices (self-hostable)
- **E2E encryption** — optional end-to-end encrypted sync with a password only you know
- **Zero telemetry** — no tracking, no fingerprinting of your device, fully auditable open source code
- **Cross-platform** — macOS, Linux, and Windows
- **Zero telemetry** — no tracking or device fingerprinting
## Install
<!-- install-links-start -->
### macOS
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut_0.17.6_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut_0.17.6_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_x64.dmg) |
Or install via Homebrew:
@@ -66,16 +61,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut_0.17.6_x64-setup.exe)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut_0.17.6_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut_0.17.6_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut-0.17.6-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut-0.17.6-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut_0.17.6_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.17.6/Donut_0.17.6_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut-0.20.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut-0.20.2-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
@@ -146,6 +140,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<sub><b>Hassiy</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/yb403">
<img src="https://avatars.githubusercontent.com/u/87396571?v=4" width="100;" alt="yb403"/>
<br />
<sub><b>yb403</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/drunkod">
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
+1
View File
@@ -17,4 +17,5 @@ COPY --from=builder /build/node_modules/ node_modules/
ENV NODE_ENV=production
EXPOSE 12342
USER node
CMD ["node", "dist/main"]
+9 -11
View File
@@ -2,8 +2,6 @@
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
@@ -28,33 +26,33 @@
## Project setup
```bash
$ pnpm install
pnpm install
```
## Compile and run the project
```bash
# development
$ pnpm run start
pnpm run start
# watch mode
$ pnpm run start:dev
pnpm run start:dev
# production mode
$ pnpm run start:prod
pnpm run start:prod
```
## Run tests
```bash
# unit tests
$ pnpm run test
pnpm run test
# e2e tests
$ pnpm run test:e2e
pnpm run test:e2e
# test coverage
$ pnpm run test:cov
pnpm run test:cov
```
## Deployment
@@ -64,8 +62,8 @@ When you're ready to deploy your NestJS application to production, there are som
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ pnpm install -g @nestjs/mau
$ mau deploy
pnpm install -g @nestjs/mau
mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
-1
View File
@@ -18,4 +18,3 @@ services:
volumes:
minio_data:
+12 -12
View File
@@ -18,33 +18,33 @@
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1015.0",
"@aws-sdk/s3-request-presigner": "^3.1015.0",
"@nestjs/common": "^11.1.17",
"@aws-sdk/client-s3": "^3.1024.0",
"@aws-sdk/s3-request-presigner": "^3.1024.0",
"@nestjs/common": "^11.1.18",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.17",
"@nestjs/platform-express": "^11.1.17",
"@nestjs/core": "^11.1.18",
"@nestjs/platform-express": "^11.1.18",
"jsonwebtoken": "^9.0.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "^11.0.9",
"@nestjs/testing": "^11.1.17",
"@nestjs/cli": "^11.0.17",
"@nestjs/schematics": "^11.0.10",
"@nestjs/testing": "^11.1.18",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/supertest": "^7.2.0",
"jest": "^30.3.0",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
"ts-loader": "^9.5.4",
"ts-jest": "^29.4.9",
"ts-loader": "^9.5.7",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.9.3"
"typescript": "^6.0.2"
},
"jest": {
"moduleFileExtensions": [
+3 -3
View File
@@ -27,7 +27,7 @@ export class AuthGuard implements CanActivate {
const request = context.switchToHttp().getRequest<Request>();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
if (!authHeader?.startsWith("Bearer ")) {
throw new UnauthorizedException(
"Missing or invalid authorization header",
);
@@ -38,7 +38,7 @@ export class AuthGuard implements CanActivate {
// Try SYNC_TOKEN first (self-hosted mode)
const expectedToken = this.configService.get<string>("SYNC_TOKEN");
if (expectedToken && token === expectedToken) {
(request as any).user = {
(request as unknown as Record<string, unknown>).user = {
mode: "self-hosted",
prefix: "",
teamPrefix: null,
@@ -55,7 +55,7 @@ export class AuthGuard implements CanActivate {
algorithms: ["RS256"],
}) as jwt.JwtPayload;
(request as any).user = {
(request as unknown as Record<string, unknown>).user = {
mode: "cloud",
prefix: decoded.prefix || `users/${decoded.sub}/`,
teamPrefix: decoded.teamPrefix || null,
+1 -1
View File
@@ -39,7 +39,7 @@ export class SyncController {
constructor(private readonly syncService: SyncService) {}
private getUserContext(req: Request): UserContext {
return (req as any).user as UserContext;
return (req as unknown as Record<string, unknown>).user as UserContext;
}
@Post("stat")
+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": ".."
}
}
+3
View File
@@ -1,4 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
},
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+2 -1
View File
@@ -13,10 +13,11 @@
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"strictPropertyInitialization": false,
"types": ["jest", "node"],
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
+5 -5
View File
@@ -94,17 +94,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.18.0";
releaseVersion = "0.20.2";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.18.0/Donut_0.18.0_amd64.AppImage";
hash = "sha256-xsN6FIkuGYPhxdX3hjQ+Ku+iVEoo721NqamOsNc3Wa8=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_amd64.AppImage";
hash = "sha256-YkdQgsDMJYVyr8590cDJnpPboHcm5X77ycfvMamUvWc=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.18.0/Donut_0.18.0_aarch64.AppImage";
hash = "sha256-UqdIVGd3DNI5nzePDvfewHsFiUE93Lgck9evNlHlDAo=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.20.2/Donut_0.20.2_aarch64.AppImage";
hash = "sha256-F3YtLU+jZw9UUE/2CcKnNxB8WPjYIdnkS5JYwoSP4qw=";
}
else
null;
+18 -11
View File
@@ -2,15 +2,16 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.18.1",
"version": "0.20.2",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
"build": "next build",
"start": "next start",
"test": "pnpm test:rust:unit && pnpm test:sync-e2e",
"test": "pnpm test:rust:unit && pnpm test:openvpn-e2e && pnpm test:sync-e2e",
"test:openvpn-e2e": "node scripts/openvpn-test-harness.mjs",
"test:rust": "cd src-tauri && cargo test",
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration",
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration",
"test:sync-e2e": "node scripts/sync-test-harness.mjs",
"lint": "pnpm lint:js && pnpm lint:rust && pnpm lint:spell",
"lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
@@ -57,27 +58,27 @@
"cmdk": "^1.1.1",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"i18next": "^25.10.5",
"lucide-react": "^0.577.0",
"i18next": "^26.0.3",
"lucide-react": "^1.7.0",
"motion": "^12.38.0",
"next": "^16.2.1",
"next": "^16.2.2",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.6.2",
"react-i18next": "^17.0.2",
"react-icons": "^5.6.0",
"recharts": "3.8.0",
"recharts": "3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.8",
"@biomejs/biome": "2.4.10",
"@tailwindcss/postcss": "^4.2.2",
"@tauri-apps/cli": "~2.10.1",
"@types/color": "^4.2.1",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
@@ -86,7 +87,13 @@
"tailwindcss": "^4.2.2",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3"
"typescript": "~6.0.2"
},
"pnpm": {
"overrides": {
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0"
}
},
"packageManager": "pnpm@10.30.1",
"lint-staged": {
+766 -766
View File
File diff suppressed because it is too large Load Diff
+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
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
/**
* OpenVPN E2E Test Harness
*
* This script:
* 1. Skips unless explicitly enabled via DONUTBROWSER_RUN_OPENVPN_E2E=1
* 2. Builds the Rust vpn_integration test binary without running it
* 3. Runs the OpenVPN e2e test binary under sudo
*
* Usage: DONUTBROWSER_RUN_OPENVPN_E2E=1 node scripts/openvpn-test-harness.mjs
*/
import { spawn } from "child_process";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, "..");
const SRC_TAURI_DIR = path.join(ROOT_DIR, "src-tauri");
const TEST_NAME = "test_openvpn_traffic_flows_through_donut_proxy";
function log(message) {
console.log(`[openvpn-harness] ${message}`);
}
function error(message) {
console.error(`[openvpn-harness] ERROR: ${message}`);
}
function shouldRun() {
if (process.env.DONUTBROWSER_RUN_OPENVPN_E2E !== "1") {
log("Skipping OpenVPN e2e test because DONUTBROWSER_RUN_OPENVPN_E2E is not set");
return false;
}
if (process.platform !== "linux") {
log(`Skipping OpenVPN e2e test on unsupported platform: ${process.platform}`);
return false;
}
return true;
}
async function buildTestBinary() {
log("Building OpenVPN e2e test binary...");
return new Promise((resolve, reject) => {
let executablePath = "";
let stdoutBuffer = "";
const proc = spawn(
"cargo",
[
"test",
"--test",
"vpn_integration",
TEST_NAME,
"--no-run",
"--message-format=json",
],
{
cwd: SRC_TAURI_DIR,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
}
);
const parseBuffer = (flush = false) => {
const lines = stdoutBuffer.split("\n");
const completeLines = flush ? lines : lines.slice(0, -1);
stdoutBuffer = flush ? "" : lines.at(-1) ?? "";
for (const line of completeLines.filter(Boolean)) {
try {
const message = JSON.parse(line);
if (message.reason === "compiler-artifact" && message.executable) {
executablePath = message.executable;
}
} catch {
// Ignore non-JSON lines.
}
}
};
proc.stdout.on("data", (data) => {
stdoutBuffer += data.toString();
parseBuffer();
});
proc.stderr.on("data", (data) => {
process.stderr.write(data);
});
proc.on("error", (err) => {
reject(err);
});
proc.on("close", (code) => {
parseBuffer(true);
if (code !== 0) {
reject(new Error(`cargo test --no-run exited with code ${code}`));
return;
}
if (!executablePath) {
reject(new Error("Could not determine the vpn_integration test binary path"));
return;
}
resolve(path.isAbsolute(executablePath) ? executablePath : path.resolve(SRC_TAURI_DIR, executablePath));
});
});
}
async function runOpenVpnE2e(executablePath) {
log("Running OpenVPN e2e test under sudo...");
return new Promise((resolve, reject) => {
const proc = spawn(
"sudo",
[
"--preserve-env=CI,GITHUB_ACTIONS,VPN_TEST_OVPN_HOST,VPN_TEST_OVPN_PORT,DONUTBROWSER_RUN_OPENVPN_E2E",
executablePath,
TEST_NAME,
"--exact",
"--nocapture",
],
{
cwd: SRC_TAURI_DIR,
env: process.env,
stdio: "inherit",
}
);
proc.on("error", (err) => {
reject(err);
});
proc.on("close", (code) => {
resolve(code ?? 1);
});
});
}
async function main() {
if (!shouldRun()) {
process.exit(0);
}
try {
const executablePath = await buildTestBinary();
const exitCode = await runOpenVpnE2e(executablePath);
process.exit(exitCode);
} catch (err) {
error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
}
main();
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
GITHUB_REPO="zhom/donutbrowser"
# Load .env if running locally
if [[ -f "$REPO_ROOT/.env" ]]; then
set -a
# shellcheck disable=SC1091
source "$REPO_ROOT/.env"
set +a
fi
# Validate required env vars
for var in R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT_URL R2_BUCKET_NAME; do
if [[ -z "${!var:-}" ]]; then
echo "Error: $var is not set. Configure it in .env or export it."
exit 1
fi
done
# Export for AWS CLI
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"
if [[ "$R2_ENDPOINT" != https://* ]]; then
R2_ENDPOINT="https://$R2_ENDPOINT"
fi
# Determine version tag
if [[ $# -ge 1 ]]; then
TAG="$1"
else
echo "Fetching latest release tag..."
TAG=$(gh release view --repo "$GITHUB_REPO" --json tagName -q .tagName)
echo "Latest release: $TAG"
fi
VERSION="${TAG#v}"
echo "Publishing repositories for version $VERSION"
# Check required tools
for cmd in aws gh dpkg-scanpackages gzip createrepo_c; do
if ! command -v "$cmd" &>/dev/null; then
echo "Error: $cmd is not installed."
case "$cmd" in
dpkg-scanpackages) echo " Install with: sudo apt-get install dpkg-dev" ;;
createrepo_c) echo " Install with: sudo apt-get install createrepo-c" ;;
aws) echo " Install with: pip install awscli" ;;
gh) echo " Install with: https://cli.github.com/" ;;
esac
exit 1
fi
done
PACKAGES_DIR="$WORK_DIR/packages"
REPO_DIR="$WORK_DIR/repo"
mkdir -p "$PACKAGES_DIR" "$REPO_DIR"
# ---------------------------------------------------------------------------
# Download .deb and .rpm from GitHub release
# ---------------------------------------------------------------------------
echo ""
echo "==> Downloading packages from GitHub release $TAG..."
gh release download "$TAG" \
--repo "$GITHUB_REPO" \
--pattern "*.deb" \
--dir "$PACKAGES_DIR"
gh release download "$TAG" \
--repo "$GITHUB_REPO" \
--pattern "*.rpm" \
--dir "$PACKAGES_DIR"
echo "Downloaded:"
ls -lh "$PACKAGES_DIR/"
# ---------------------------------------------------------------------------
# DEB repository
# ---------------------------------------------------------------------------
echo ""
echo "==> Building DEB repository..."
DEB_DIR="$REPO_DIR/deb"
mkdir -p "$DEB_DIR/pool/main"
mkdir -p "$DEB_DIR/dists/stable/main/binary-amd64"
mkdir -p "$DEB_DIR/dists/stable/main/binary-arm64"
# Pull existing pool from R2 (incremental)
echo " Syncing existing DEB pool from R2..."
aws s3 sync "s3://${R2_BUCKET_NAME}/deb/pool" "$DEB_DIR/pool" \
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || true
# Copy new .deb files into pool
for deb in "$PACKAGES_DIR"/*.deb; do
[[ -f "$deb" ]] || continue
cp "$deb" "$DEB_DIR/pool/main/"
done
# Generate Packages and Packages.gz for each arch
for arch in amd64 arm64; do
echo " Generating Packages for $arch..."
BINARY_DIR="$DEB_DIR/dists/stable/main/binary-${arch}"
# dpkg-scanpackages needs to run from the repo root
# and needs paths relative to that root
(cd "$DEB_DIR" && dpkg-scanpackages --arch "$arch" pool/main) \
> "$BINARY_DIR/Packages"
gzip -9c "$BINARY_DIR/Packages" > "$BINARY_DIR/Packages.gz"
echo " $(grep -c '^Package:' "$BINARY_DIR/Packages" 2>/dev/null || echo 0) package(s)"
done
# Generate Release file
echo " Generating Release file..."
{
echo "Origin: Donut Browser"
echo "Label: Donut Browser"
echo "Suite: stable"
echo "Codename: stable"
echo "Architectures: amd64 arm64"
echo "Components: main"
echo "Date: $(date -u '+%a, %d %b %Y %H:%M:%S UTC')"
echo "MD5Sum:"
for arch in amd64 arm64; do
for file in "main/binary-${arch}/Packages" "main/binary-${arch}/Packages.gz"; do
filepath="$DEB_DIR/dists/stable/$file"
if [[ -f "$filepath" ]]; then
size=$(wc -c < "$filepath")
md5=$(md5sum "$filepath" | awk '{print $1}')
printf " %s %8d %s\n" "$md5" "$size" "$file"
fi
done
done
echo "SHA256:"
for arch in amd64 arm64; do
for file in "main/binary-${arch}/Packages" "main/binary-${arch}/Packages.gz"; do
filepath="$DEB_DIR/dists/stable/$file"
if [[ -f "$filepath" ]]; then
size=$(wc -c < "$filepath")
sha256=$(sha256sum "$filepath" | awk '{print $1}')
printf " %s %8d %s\n" "$sha256" "$size" "$file"
fi
done
done
} > "$DEB_DIR/dists/stable/Release"
echo " DEB Release file created."
# ---------------------------------------------------------------------------
# RPM repository
# ---------------------------------------------------------------------------
echo ""
echo "==> Building RPM repository..."
RPM_DIR="$REPO_DIR/rpm"
mkdir -p "$RPM_DIR/x86_64"
mkdir -p "$RPM_DIR/aarch64"
# Pull existing RPMs from R2 (incremental)
echo " Syncing existing RPM packages from R2..."
aws s3 sync "s3://${R2_BUCKET_NAME}/rpm/x86_64" "$RPM_DIR/x86_64" \
--endpoint-url "$R2_ENDPOINT" --exclude "repodata/*" 2>/dev/null || true
aws s3 sync "s3://${R2_BUCKET_NAME}/rpm/aarch64" "$RPM_DIR/aarch64" \
--endpoint-url "$R2_ENDPOINT" --exclude "repodata/*" 2>/dev/null || true
# Copy new .rpm files into arch directories
for rpm in "$PACKAGES_DIR"/*.rpm; do
[[ -f "$rpm" ]] || continue
filename=$(basename "$rpm")
if [[ "$filename" == *x86_64* ]]; then
cp "$rpm" "$RPM_DIR/x86_64/"
elif [[ "$filename" == *aarch64* ]]; then
cp "$rpm" "$RPM_DIR/aarch64/"
fi
done
# Generate repodata using createrepo_c
# We point createrepo_c at the top-level rpm dir so it indexes all subdirs
echo " Generating RPM repodata..."
createrepo_c --update "$RPM_DIR"
echo " RPM repodata created."
# ---------------------------------------------------------------------------
# Upload to R2
# ---------------------------------------------------------------------------
echo ""
echo "==> Uploading DEB repository to R2..."
aws s3 sync "$DEB_DIR/dists" "s3://${R2_BUCKET_NAME}/deb/dists" \
--endpoint-url "$R2_ENDPOINT" --delete
aws s3 sync "$DEB_DIR/pool" "s3://${R2_BUCKET_NAME}/deb/pool" \
--endpoint-url "$R2_ENDPOINT"
echo "==> Uploading RPM repository to R2..."
aws s3 sync "$RPM_DIR" "s3://${R2_BUCKET_NAME}/rpm" \
--endpoint-url "$R2_ENDPOINT"
# ---------------------------------------------------------------------------
# Verify
# ---------------------------------------------------------------------------
echo ""
echo "==> Verifying upload..."
echo "DEB dists/stable/:"
aws s3 ls "s3://${R2_BUCKET_NAME}/deb/dists/stable/" \
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty or not accessible)"
echo "DEB pool/main/:"
aws s3 ls "s3://${R2_BUCKET_NAME}/deb/pool/main/" \
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty or not accessible)"
echo "RPM repodata/:"
aws s3 ls "s3://${R2_BUCKET_NAME}/rpm/repodata/" \
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty or not accessible)"
echo ""
echo "Done! Repository published for $TAG"
echo ""
echo "Users can add the DEB repo with:"
echo " echo 'deb [trusted=yes] https://repo.donutbrowser.com/deb stable main' | sudo tee /etc/apt/sources.list.d/donutbrowser.list"
echo " sudo apt update && sudo apt install donut"
echo ""
echo "Users can add the RPM repo with:"
echo " sudo tee /etc/yum.repos.d/donutbrowser.repo << 'EOF'"
echo " [donutbrowser]"
echo " name=Donut Browser"
echo " baseurl=https://repo.donutbrowser.com/rpm"
echo " enabled=1"
echo " gpgcheck=0"
echo " EOF"
echo " sudo dnf install Donut"
+233 -129
View File
@@ -607,16 +607,16 @@ dependencies = [
[[package]]
name = "blake3"
version = "1.8.3"
version = "1.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d"
checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq 0.4.2",
"cpufeatures 0.2.17",
"cpufeatures 0.3.0",
]
[[package]]
@@ -920,9 +920,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.57"
version = "1.2.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1461,6 +1461,17 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "dbus"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4"
dependencies = [
"libc",
"libdbus-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "deadpool"
version = "0.12.3"
@@ -1694,7 +1705,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.18.1"
version = "0.20.2"
dependencies = [
"aes",
"aes-gcm",
@@ -1747,10 +1758,11 @@ dependencies = [
"serde_yaml",
"serial_test",
"sha1",
"sha2",
"smoltcp",
"sys-locale",
"sysinfo",
"tao",
"tao 0.35.0",
"tar",
"tauri",
"tauri-build",
@@ -1769,7 +1781,7 @@ dependencies = [
"tokio-util",
"tower",
"tower-http",
"tray-icon",
"tray-icon 0.22.0",
"url",
"urlencoding",
"utoipa",
@@ -1778,7 +1790,7 @@ dependencies = [
"windows 0.62.2",
"winreg 0.56.0",
"wiremock",
"zip 8.4.0",
"zip 8.5.0",
]
[[package]]
@@ -1825,9 +1837,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embed-resource"
version = "3.0.7"
version = "3.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ec73ddcf6b7f23173d5c3c5a32b5507dc0a734de7730aa14abc5d5e296bb5f"
checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45"
dependencies = [
"cc",
"memchr",
@@ -2866,9 +2878,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.8.1"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
@@ -2881,7 +2893,6 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
"want",
@@ -2981,12 +2992,13 @@ dependencies = [
[[package]]
name = "icu_collections"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
@@ -2994,9 +3006,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
@@ -3007,9 +3019,9 @@ dependencies = [
[[package]]
name = "icu_normalizer"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@@ -3021,15 +3033,15 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
version = "2.1.2"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
@@ -3041,15 +3053,15 @@ dependencies = [
[[package]]
name = "icu_properties_data"
version = "2.1.2"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
@@ -3228,9 +3240,9 @@ checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763"
[[package]]
name = "iri-string"
version = "0.7.11"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
dependencies = [
"memchr",
"serde",
@@ -3379,10 +3391,12 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.91"
version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -3487,9 +3501,18 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.183"
version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "libdbus-sys"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
dependencies = [
"pkg-config",
]
[[package]]
name = "libfuzzer-sys"
@@ -3519,9 +3542,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.14"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
dependencies = [
"bitflags 2.11.0",
"libc",
@@ -3567,9 +3590,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "lock_api"
@@ -3770,9 +3793,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.1.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
@@ -3815,9 +3838,9 @@ dependencies = [
[[package]]
name = "muda"
version = "0.17.1"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a"
checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177"
dependencies = [
"crossbeam-channel",
"dpi",
@@ -3954,9 +3977,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.2.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-derive"
@@ -4126,6 +4149,16 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-core-location"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
@@ -4219,8 +4252,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.11.0",
"block2",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-location",
"objc2-core-text",
"objc2-foundation",
"objc2-quartz-core",
"objc2-user-notifications",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
"objc2",
"objc2-foundation",
]
@@ -4664,12 +4716,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.5"
@@ -4809,9 +4855,9 @@ dependencies = [
[[package]]
name = "potential_utf"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
@@ -4873,7 +4919,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.8+spec-1.1.0",
"toml_edit 0.25.10+spec-1.1.0",
]
[[package]]
@@ -5568,9 +5614,9 @@ dependencies = [
[[package]]
name = "rust_decimal"
version = "1.40.0"
version = "1.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0"
checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a"
dependencies = [
"arrayvec",
"borsh",
@@ -5580,13 +5626,14 @@ dependencies = [
"rkyv",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
@@ -5831,9 +5878,9 @@ dependencies = [
[[package]]
name = "semver"
version = "1.0.27"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
dependencies = [
"serde",
"serde_core",
@@ -5938,9 +5985,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "1.1.0"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
@@ -6140,9 +6187,9 @@ dependencies = [
[[package]]
name = "simd-adler32"
version = "0.3.8"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simd_helpers"
@@ -6528,6 +6575,46 @@ dependencies = [
"x11-dl",
]
[[package]]
name = "tao"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159"
dependencies = [
"bitflags 2.11.0",
"block2",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dbus",
"dispatch2",
"dlopen2",
"dpi",
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"libc",
"log",
"ndk",
"ndk-sys",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"objc2-ui-kit",
"once_cell",
"parking_lot",
"percent-encoding",
"raw-window-handle",
"tao-macros",
"unicode-segmentation",
"url",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
]
[[package]]
name = "tao-macros"
version = "0.1.3"
@@ -6605,7 +6692,7 @@ dependencies = [
"tauri-utils",
"thiserror 2.0.18",
"tokio",
"tray-icon",
"tray-icon 0.21.3",
"url",
"webkit2gtk",
"webview2-com",
@@ -6890,7 +6977,7 @@ dependencies = [
"percent-encoding",
"raw-window-handle",
"softbuffer",
"tao",
"tao 0.34.8",
"tauri-runtime",
"tauri-utils",
"url",
@@ -7107,9 +7194,9 @@ dependencies = [
[[package]]
name = "tinystr"
version = "0.8.2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
@@ -7132,9 +7219,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.50.0"
version = "1.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
dependencies = [
"bytes",
"libc",
@@ -7149,9 +7236,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.6.1"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
@@ -7249,7 +7336,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap 2.13.0",
"serde_core",
"serde_spanned 1.1.0",
"serde_spanned 1.1.1",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
@@ -7276,9 +7363,9 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "1.1.0+spec-1.1.0"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
@@ -7309,30 +7396,30 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.8+spec-1.1.0"
version = "0.25.10+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b"
dependencies = [
"indexmap 2.13.0",
"toml_datetime 1.1.0+spec-1.1.0",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.0",
"winnow 1.0.1",
]
[[package]]
name = "toml_parser"
version = "1.1.0+spec-1.1.0"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.0",
"winnow 1.0.1",
]
[[package]]
name = "toml_writer"
version = "1.1.0+spec-1.1.0"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "tower"
@@ -7444,6 +7531,27 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "tray-icon"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e1484378c343c5a9b291188fa58917c7184967683f8cfe4a05461986970553"
dependencies = [
"crossbeam-channel",
"dirs",
"libappindicator",
"muda",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png 0.18.1",
"thiserror 2.0.18",
"windows-sys 0.60.2",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -7607,9 +7715,9 @@ checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-vo"
@@ -7767,9 +7875,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.22.0"
version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -7883,9 +7991,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
dependencies = [
"cfg-if",
"once_cell",
@@ -7896,23 +8004,19 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.64"
version = "0.4.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
dependencies = [
"cfg-if",
"futures-util",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -7920,9 +8024,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.114"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -7933,9 +8037,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.114"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
dependencies = [
"unicode-ident",
]
@@ -7989,9 +8093,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.91"
version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -8622,9 +8726,9 @@ dependencies = [
[[package]]
name = "winnow"
version = "1.0.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
dependencies = [
"memchr",
]
@@ -8762,9 +8866,9 @@ dependencies = [
[[package]]
name = "writeable"
version = "0.6.2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "wry"
@@ -8885,9 +8989,9 @@ checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]]
name = "yoke"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [
"stable_deref_trait",
"yoke-derive",
@@ -8896,9 +9000,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
@@ -8969,18 +9073,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.47"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.47"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
@@ -8989,18 +9093,18 @@ dependencies = [
[[package]]
name = "zerofrom"
version = "0.1.6"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.6"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
@@ -9030,9 +9134,9 @@ dependencies = [
[[package]]
name = "zerotrie"
version = "0.2.3"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
@@ -9041,9 +9145,9 @@ dependencies = [
[[package]]
name = "zerovec"
version = "0.11.5"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
@@ -9052,9 +9156,9 @@ dependencies = [
[[package]]
name = "zerovec-derive"
version = "0.11.2"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
@@ -9093,9 +9197,9 @@ dependencies = [
[[package]]
name = "zip"
version = "8.4.0"
version = "8.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7756d0206d058333667493c4014f545f4b9603c4330ccd6d9b3f86dcab59f7d9"
checksum = "2726508a48f38dceb22b35ecbbd2430efe34ff05c62bd3285f965d7911b33464"
dependencies = [
"crc32fast",
"flate2",
@@ -9167,9 +9271,9 @@ dependencies = [
[[package]]
name = "zune-jpeg"
version = "0.5.14"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+5 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.18.1"
version = "0.20.2"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -64,7 +64,7 @@ flate2 = "1"
lzma-rs = "0"
msi-extract = "0"
uuid = { version = "1.20", features = ["v4", "serde"] }
uuid = { version = "1.23", features = ["v4", "serde"] }
url = "2.5"
blake3 = "1"
globset = "0.4"
@@ -85,6 +85,7 @@ aes = "0.8"
cbc = "0.1"
pbkdf2 = "0.12"
sha1 = "0.10"
sha2 = "0.10"
hyper = { version = "1.8", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
@@ -109,9 +110,9 @@ boringtun = "0.7"
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
# Daemon dependencies (tray icon)
tray-icon = "0.21"
tray-icon = "0.22"
muda = "0.17"
tao = "0.34"
tao = "0.35"
image = "0.25"
dirs = "6"
crossbeam-channel = "0.5"
+31 -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(),
@@ -611,6 +610,8 @@ async fn create_profile(
wayfern_config,
request.group_id.clone(),
false,
None,
request.launch_hook.clone(),
)
.await
{
@@ -640,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,
@@ -713,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 {
@@ -1034,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(),
@@ -1069,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)
@@ -1096,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),
@@ -1147,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),
+48 -5
View File
@@ -109,6 +109,8 @@ pub struct AppUpdateInfo {
pub published_at: String,
pub manual_update_required: bool,
pub release_page_url: Option<String>,
/// True when a system package manager repo is configured (apt/dnf/zypper)
pub repo_update: bool,
}
pub struct AppAutoUpdater {
@@ -212,11 +214,12 @@ impl AppAutoUpdater {
// Find the appropriate asset for current platform
let download_url = self.get_download_url_for_platform(&latest_release.assets);
// On Linux, we show the update notification even if auto-update is disabled
// Users can manually download from the release page
// On Linux, when a package repo is configured, notify users to update via
// their package manager instead of auto-downloading from GitHub.
#[cfg(target_os = "linux")]
{
let manual_update_required = download_url.is_none();
let repo_update = self.is_repo_configured();
let manual_update_required = download_url.is_none() || repo_update;
let update_info = AppUpdateInfo {
current_version,
new_version: latest_release.tag_name.clone(),
@@ -226,13 +229,15 @@ impl AppAutoUpdater {
published_at: latest_release.published_at.clone(),
manual_update_required,
release_page_url: Some(release_page_url),
repo_update,
};
log::info!(
"Update info prepared: {} -> {} (manual_update_required: {})",
"Update info prepared: {} -> {} (manual_update_required: {}, repo_update: {})",
update_info.current_version,
update_info.new_version,
update_info.manual_update_required
update_info.manual_update_required,
update_info.repo_update
);
return Ok(Some(update_info));
}
@@ -249,6 +254,7 @@ impl AppAutoUpdater {
published_at: latest_release.published_at.clone(),
manual_update_required: false,
release_page_url: Some(release_page_url),
repo_update: false,
};
log::info!(
@@ -455,6 +461,30 @@ impl AppAutoUpdater {
LinuxInstallationMethod::Unknown
}
/// Check if the APT repository is configured
#[cfg(target_os = "linux")]
fn is_deb_repo_configured() -> bool {
Path::new("/etc/apt/sources.list.d/donutbrowser.list").exists()
}
/// Check if an RPM repository is configured (yum/dnf or zypper)
#[cfg(target_os = "linux")]
fn is_rpm_repo_configured() -> bool {
Path::new("/etc/yum.repos.d/donutbrowser.repo").exists()
|| Path::new("/etc/zypp/repos.d/donutbrowser.repo").exists()
}
/// Check if a system package manager repo is configured for this installation.
#[cfg(target_os = "linux")]
fn is_repo_configured(&self) -> bool {
let installation_method = self.detect_linux_installation_method();
match installation_method {
LinuxInstallationMethod::Deb => Self::is_deb_repo_configured(),
LinuxInstallationMethod::Rpm => Self::is_rpm_repo_configured(),
_ => false,
}
}
/// Get the appropriate download URL for the current platform
fn get_download_url_for_platform(&self, assets: &[AppReleaseAsset]) -> Option<String> {
let arch = if cfg!(target_arch = "aarch64") {
@@ -1604,6 +1634,10 @@ rm "{}"
#[tauri::command]
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
if crate::app_dirs::is_portable() {
log::info!("App auto-updates disabled in portable mode");
return Ok(None);
}
// The disable_auto_updates setting controls app self-updates only
let disabled = crate::settings_manager::SettingsManager::instance()
.load_settings()
@@ -2001,6 +2035,15 @@ mod tests {
// If url is None, it means AppImage was detected and auto-updates are disabled
}
}
#[test]
#[cfg(target_os = "linux")]
fn test_repo_detection_returns_bool() {
// These just verify the functions run without panicking.
// Actual values depend on the host system configuration.
let _deb = AppAutoUpdater::is_deb_repo_configured();
let _rpm = AppAutoUpdater::is_rpm_repo_configured();
}
}
// Global singleton instance
+31
View File
@@ -3,11 +3,29 @@ use std::path::PathBuf;
use std::sync::OnceLock;
static BASE_DIRS: OnceLock<BaseDirs> = OnceLock::new();
static PORTABLE_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
fn base_dirs() -> &'static BaseDirs {
BASE_DIRS.get_or_init(|| BaseDirs::new().expect("Failed to get base directories"))
}
/// Returns the portable base directory if a `.portable` marker exists next to the executable.
fn portable_dir() -> Option<&'static PathBuf> {
PORTABLE_DIR
.get_or_init(|| {
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|p| p.to_path_buf()))
.filter(|dir| dir.join(".portable").exists())
})
.as_ref()
}
/// Returns true if the app is running in portable mode.
pub fn is_portable() -> bool {
portable_dir().is_some()
}
pub fn app_name() -> &'static str {
if cfg!(debug_assertions) {
"DonutBrowserDev"
@@ -28,6 +46,10 @@ pub fn data_dir() -> PathBuf {
return PathBuf::from(dir);
}
if let Some(dir) = portable_dir() {
return dir.join("data");
}
base_dirs().data_local_dir().join(app_name())
}
@@ -43,6 +65,10 @@ pub fn cache_dir() -> PathBuf {
return PathBuf::from(dir);
}
if let Some(dir) = portable_dir() {
return dir.join("cache");
}
base_dirs().cache_dir().join(app_name())
}
@@ -78,6 +104,10 @@ pub fn extensions_dir() -> PathBuf {
data_dir().join("extensions")
}
pub fn dns_blocklist_dir() -> PathBuf {
cache_dir().join("dns_blocklists")
}
#[cfg(test)]
thread_local! {
static TEST_DATA_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
@@ -162,6 +192,7 @@ mod tests {
assert!(proxy_workers_dir().ends_with("proxy_workers"));
assert!(vpn_dir().ends_with("vpn"));
assert!(extensions_dir().ends_with("extensions"));
assert!(dns_blocklist_dir().ends_with("dns_blocklists"));
}
#[test]
+2
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,
@@ -699,6 +700,7 @@ mod tests {
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
}
}
+15 -1
View File
@@ -152,6 +152,11 @@ async fn main() {
Arg::new("bypass-rules")
.long("bypass-rules")
.help("JSON array of bypass rules (hostnames, IPs, or regex patterns)"),
)
.arg(
Arg::new("blocklist-file")
.long("blocklist-file")
.help("Path to DNS blocklist file (one domain per line)"),
),
)
.subcommand(
@@ -235,8 +240,17 @@ async fn main() {
.get_one::<String>("bypass-rules")
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
let blocklist_file = start_matches.get_one::<String>("blocklist-file").cloned();
match start_proxy_process_with_profile(upstream_url, port, profile_id, bypass_rules).await {
match start_proxy_process_with_profile(
upstream_url,
port,
profile_id,
bypass_rules,
blocklist_file,
)
.await
{
Ok(config) => {
// Output the configuration as JSON for the Rust side to parse
// Use println! here because this needs to go to stdout for parsing
+2
View File
@@ -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(),
@@ -1216,6 +1217,7 @@ mod tests {
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
};
let path = profile.get_profile_data_path(&profiles_dir);
+67 -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,
@@ -38,10 +38,28 @@ impl BrowserRunner {
crate::app_dirs::binaries_dir()
}
/// Resolve the DNS blocklist level to a cached file path.
/// If a level is set but the cache is missing, fetches on demand (blocks until done).
async fn resolve_blocklist_file(
profile: &crate::profile::BrowserProfile,
) -> Result<Option<String>, String> {
let Some(ref level_str) = profile.dns_blocklist else {
return Ok(None);
};
let Some(level) = crate::dns_blocklist::BlocklistLevel::parse_level(level_str) else {
return Ok(None);
};
if level == crate::dns_blocklist::BlocklistLevel::None {
return Ok(None);
}
let path = crate::dns_blocklist::BlocklistManager::ensure_cached(level)
.await
.map_err(|e| format!("Failed to fetch DNS blocklist: {e}"))?;
Ok(Some(path.to_string_lossy().to_string()))
}
/// 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>,
@@ -52,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;
@@ -72,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(
@@ -127,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() })?;
@@ -168,6 +210,7 @@ impl BrowserRunner {
// Start the proxy and get local proxy settings
// If proxy startup fails, DO NOT launch Camoufox - it requires local proxy
let profile_id_str = profile.id.to_string();
let blocklist_file = Self::resolve_blocklist_file(profile).await?;
let local_proxy = PROXY_MANAGER
.start_proxy(
app_handle.clone(),
@@ -175,6 +218,7 @@ impl BrowserRunner {
0, // Use 0 as temporary PID, will be updated later
Some(&profile_id_str),
profile.proxy_bypass_rules.clone(),
blocklist_file,
)
.await
.map_err(|e| {
@@ -386,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() })?;
@@ -427,6 +470,7 @@ impl BrowserRunner {
// Start the proxy and get local proxy settings
// If proxy startup fails, DO NOT launch Wayfern - it requires local proxy
let profile_id_str = profile.id.to_string();
let blocklist_file = Self::resolve_blocklist_file(profile).await?;
let local_proxy = PROXY_MANAGER
.start_proxy(
app_handle.clone(),
@@ -434,6 +478,7 @@ impl BrowserRunner {
0, // Use 0 as temporary PID, will be updated later
Some(&profile_id_str),
profile.proxy_bypass_rules.clone(),
blocklist_file,
)
.await
.map_err(|e| {
@@ -739,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() })?;
@@ -751,6 +794,9 @@ impl BrowserRunner {
let profile_id_str = profile.id.to_string();
// Start local proxy - if this fails, DO NOT launch browser
let blocklist_file = Self::resolve_blocklist_file(profile)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
let internal_proxy = PROXY_MANAGER
.start_proxy(
app_handle.clone(),
@@ -758,6 +804,7 @@ impl BrowserRunner {
temp_pid,
Some(&profile_id_str),
profile.proxy_bypass_rules.clone(),
blocklist_file,
)
.await
.map_err(|e| {
@@ -2245,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
@@ -2280,6 +2324,7 @@ pub async fn launch_browser_profile(
// Always start a local proxy, even if there's no upstream proxy
// This allows for traffic monitoring and future features
let blocklist_file = BrowserRunner::resolve_blocklist_file(&profile_for_launch).await?;
match PROXY_MANAGER
.start_proxy(
app_handle.clone(),
@@ -2287,6 +2332,7 @@ pub async fn launch_browser_profile(
temp_pid,
Some(&profile_id_str),
profile_for_launch.proxy_bypass_rules.clone(),
blocklist_file,
)
.await
{
+4 -4
View File
@@ -362,12 +362,12 @@ impl CloudAuthManager {
// --- API methods ---
pub async fn request_otp(&self, email: &str) -> Result<String, String> {
pub async fn request_otp(&self, email: &str, captcha_token: &str) -> Result<String, String> {
let url = format!("{CLOUD_API_URL}/api/auth/otp/request");
let response = self
.client
.post(&url)
.json(&serde_json::json!({ "email": email }))
.json(&serde_json::json!({ "email": email, "captchaToken": captcha_token }))
.send()
.await
.map_err(|e| format!("Failed to request OTP: {e}"))?;
@@ -1100,8 +1100,8 @@ impl CloudAuthManager {
// --- Tauri commands ---
#[tauri::command]
pub async fn cloud_request_otp(email: String) -> Result<String, String> {
CLOUD_AUTH.request_otp(&email).await
pub async fn cloud_request_otp(email: String, captcha_token: String) -> Result<String, String> {
CLOUD_AUTH.request_otp(&email, &captcha_token).await
}
#[tauri::command]
File diff suppressed because it is too large Load Diff
+3
View File
@@ -340,6 +340,9 @@ pub fn is_autostart_enabled() -> bool {
}
pub fn get_data_dir() -> Option<PathBuf> {
if crate::app_dirs::is_portable() {
return Some(crate::app_dirs::data_dir());
}
if let Some(proj_dirs) = ProjectDirs::from("com", "donutbrowser", "Donut Browser") {
Some(proj_dirs.data_dir().to_path_buf())
} else {
+343
View File
@@ -0,0 +1,343 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use crate::app_dirs;
const REFRESH_INTERVAL: Duration = Duration::from_secs(43200); // 12 hours
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum BlocklistLevel {
#[default]
None,
Light,
Normal,
Pro,
ProPlus,
Ultimate,
}
impl BlocklistLevel {
pub fn parse_level(s: &str) -> Option<Self> {
match s {
"light" => Some(Self::Light),
"normal" => Some(Self::Normal),
"pro" => Some(Self::Pro),
"pro_plus" => Some(Self::ProPlus),
"ultimate" => Some(Self::Ultimate),
"none" => Some(Self::None),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::None => "none",
Self::Light => "light",
Self::Normal => "normal",
Self::Pro => "pro",
Self::ProPlus => "pro_plus",
Self::Ultimate => "ultimate",
}
}
pub fn display_name(&self) -> &'static str {
match self {
Self::None => "None",
Self::Light => "Light",
Self::Normal => "Normal",
Self::Pro => "Pro",
Self::ProPlus => "Pro++",
Self::Ultimate => "Ultimate",
}
}
pub fn url(&self) -> Option<&'static str> {
match self {
Self::None => None,
Self::Light => {
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt")
}
Self::Normal => {
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/multi.txt")
}
Self::Pro => Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.txt"),
Self::ProPlus => {
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.plus.txt")
}
Self::Ultimate => {
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/ultimate.txt")
}
}
}
pub fn filename(&self) -> Option<&'static str> {
match self {
Self::None => None,
Self::Light => Some("light.txt"),
Self::Normal => Some("multi.txt"),
Self::Pro => Some("pro.txt"),
Self::ProPlus => Some("pro.plus.txt"),
Self::Ultimate => Some("ultimate.txt"),
}
}
pub fn all_downloadable() -> &'static [BlocklistLevel] {
&[
Self::Light,
Self::Normal,
Self::Pro,
Self::ProPlus,
Self::Ultimate,
]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlocklistCacheStatus {
pub level: String,
pub display_name: String,
pub entry_count: usize,
pub file_size_bytes: u64,
pub last_updated: Option<u64>,
pub is_fresh: bool,
pub is_cached: bool,
}
pub struct BlocklistManager;
lazy_static::lazy_static! {
static ref HTTP_CLIENT: reqwest::Client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.expect("Failed to create HTTP client");
}
impl BlocklistManager {
pub fn instance() -> &'static BlocklistManager {
&BLOCKLIST_MANAGER
}
fn cache_dir() -> PathBuf {
app_dirs::dns_blocklist_dir()
}
pub fn cached_file_path(level: BlocklistLevel) -> Option<PathBuf> {
level.filename().map(|f| Self::cache_dir().join(f))
}
pub fn is_cache_fresh(level: BlocklistLevel) -> bool {
let Some(path) = Self::cached_file_path(level) else {
return false;
};
if !path.exists() {
return false;
}
match std::fs::metadata(&path).and_then(|m| m.modified()) {
Ok(modified) => SystemTime::now()
.duration_since(modified)
.map(|age| age < REFRESH_INTERVAL)
.unwrap_or(false),
Err(_) => false,
}
}
pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> {
let url = level
.url()
.ok_or_else(|| format!("No URL for level {:?}", level))?;
let path =
Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?;
let cache_dir = Self::cache_dir();
std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?;
log::info!(
"[dns-blocklist] Fetching {} from {}",
level.display_name(),
url
);
let response = HTTP_CLIENT
.get(url)
.send()
.await
.map_err(|e| format!("Failed to fetch blocklist: {e}"))?;
if !response.status().is_success() {
return Err(format!("HTTP {} when fetching {}", response.status(), url));
}
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response body: {e}"))?;
// Write atomically: write to temp file, then rename
let tmp_path = path.with_extension("tmp");
std::fs::write(&tmp_path, &body).map_err(|e| format!("Failed to write blocklist: {e}"))?;
std::fs::rename(&tmp_path, &path).map_err(|e| format!("Failed to rename blocklist: {e}"))?;
let entry_count = body
.lines()
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
.count();
log::info!(
"[dns-blocklist] Cached {} ({} domains)",
level.display_name(),
entry_count
);
Ok(path)
}
pub async fn ensure_cached(level: BlocklistLevel) -> Result<PathBuf, String> {
if let Some(path) = Self::cached_file_path(level) {
if path.exists() {
return Ok(path);
}
}
Self::fetch_blocklist(level).await
}
pub async fn refresh_all_stale(&self) {
for &level in BlocklistLevel::all_downloadable() {
if !Self::is_cache_fresh(level) {
if let Err(e) = Self::fetch_blocklist(level).await {
log::error!(
"[dns-blocklist] Failed to refresh {}: {e}",
level.display_name()
);
let _ = crate::events::emit(
"dns-blocklist-refresh-failed",
serde_json::json!({
"level": level.as_str(),
"error": e,
}),
);
}
}
}
}
pub fn get_blocklist_file_path(level: BlocklistLevel) -> Option<PathBuf> {
Self::cached_file_path(level).filter(|p| p.exists())
}
pub fn get_cache_status() -> Vec<BlocklistCacheStatus> {
BlocklistLevel::all_downloadable()
.iter()
.map(|&level| {
let path = Self::cached_file_path(level);
let metadata = path.as_ref().and_then(|p| std::fs::metadata(p).ok());
let is_cached = metadata.is_some();
let entry_count = if is_cached {
path
.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|content| {
content
.lines()
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
.count()
})
.unwrap_or(0)
} else {
0
};
let file_size_bytes = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
let last_updated = metadata
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs());
BlocklistCacheStatus {
level: level.as_str().to_string(),
display_name: level.display_name().to_string(),
entry_count,
file_size_bytes,
last_updated,
is_fresh: Self::is_cache_fresh(level),
is_cached,
}
})
.collect()
}
}
lazy_static::lazy_static! {
static ref BLOCKLIST_MANAGER: BlocklistManager = BlocklistManager;
}
// Tauri commands
#[tauri::command]
pub async fn get_dns_blocklist_cache_status() -> Result<Vec<BlocklistCacheStatus>, String> {
Ok(BlocklistManager::get_cache_status())
}
#[tauri::command]
pub async fn refresh_dns_blocklists() -> Result<(), String> {
for &level in BlocklistLevel::all_downloadable() {
BlocklistManager::fetch_blocklist(level).await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_level_roundtrip() {
for &level in BlocklistLevel::all_downloadable() {
let s = level.as_str();
let parsed = BlocklistLevel::parse_level(s);
assert_eq!(parsed, Some(level), "Roundtrip failed for {s}");
}
assert_eq!(
BlocklistLevel::parse_level("none"),
Some(BlocklistLevel::None)
);
}
#[test]
fn test_level_urls_all_present() {
for &level in BlocklistLevel::all_downloadable() {
assert!(
level.url().is_some(),
"{} should have a URL",
level.as_str()
);
assert!(
level.filename().is_some(),
"{} should have a filename",
level.as_str()
);
}
assert!(BlocklistLevel::None.url().is_none());
assert!(BlocklistLevel::None.filename().is_none());
}
#[test]
fn test_cache_status_returns_all_levels() {
let statuses = BlocklistManager::get_cache_status();
assert_eq!(statuses.len(), 5);
assert_eq!(statuses[0].level, "light");
assert_eq!(statuses[1].level, "normal");
assert_eq!(statuses[2].level, "pro");
assert_eq!(statuses[3].level, "pro_plus");
assert_eq!(statuses[4].level, "ultimate");
}
#[test]
fn test_cache_fresh_returns_false_when_missing() {
assert!(!BlocklistManager::is_cache_fresh(BlocklistLevel::Light));
assert!(!BlocklistManager::is_cache_fresh(BlocklistLevel::None));
}
}
+3
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(),
@@ -277,6 +278,7 @@ mod tests {
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
}
}
@@ -313,6 +315,7 @@ mod tests {
}
#[test]
#[serial_test::serial]
fn test_recover_ephemeral_dirs() {
let base = get_ephemeral_base_dir().unwrap();
let test_id = uuid::Uuid::new_v4().to_string();
+16 -7
View File
@@ -55,6 +55,7 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, IpError> {
let proxy = reqwest::Proxy::all(proxy_url)
.map_err(|e| IpError::Network(format!("Invalid proxy: {}", e)))?;
client_builder
.no_proxy()
.proxy(proxy)
.build()
.map_err(|e| IpError::Network(e.to_string()))?
@@ -64,7 +65,7 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, IpError> {
.map_err(|e| IpError::Network(e.to_string()))?
};
let mut last_error = None;
let mut errors = Vec::new();
for url in &urls {
match client.get(*url).send().await {
@@ -76,21 +77,29 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, IpError> {
}
}
Err(e) => {
last_error = Some(format!("Failed to read response from {}: {}", url, e));
errors.push(format!("{}: {}", url, e));
}
},
Ok(response) => {
last_error = Some(format!("HTTP {} from {}", response.status(), url));
errors.push(format!("{}: HTTP {}", url, response.status()));
}
Err(e) => {
last_error = Some(format!("Request to {} failed: {}", url, e));
errors.push(format!("{}: {}", url, e));
}
}
}
Err(IpError::Network(last_error.unwrap_or_else(|| {
"Failed to fetch public IP from any endpoint".to_string()
})))
if errors.is_empty() {
Err(IpError::Network(
"Failed to fetch public IP from any endpoint".to_string(),
))
} else {
Err(IpError::Network(format!(
"All {} endpoints failed: {}",
errors.len(),
errors.join("; ")
)))
}
}
#[cfg(test)]
+132 -84
View File
@@ -19,6 +19,7 @@ mod browser_version_manager;
pub mod camoufox;
mod camoufox_manager;
mod default_browser;
pub mod dns_blocklist;
mod downloaded_browsers_registry;
mod downloader;
mod ephemeral_dirs;
@@ -65,8 +66,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_note,
update_profile_proxy, update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn,
list_browser_profiles, rename_profile, update_camoufox_config, update_profile_dns_blocklist,
update_profile_launch_hook, update_profile_note, update_profile_proxy,
update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn,
update_wayfern_config,
};
@@ -85,7 +87,7 @@ use downloader::{cancel_download, download_browser};
use settings_manager::{
decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings,
get_sync_settings, get_system_language, get_table_sorting_settings,
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
get_window_resize_warning_dismissed, save_app_settings, save_sync_settings,
save_table_sorting_settings, should_show_launch_on_login_prompt,
};
@@ -211,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())
}
}
@@ -238,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]
@@ -272,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)
@@ -289,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)
@@ -812,6 +769,42 @@ async fn download_geoip_database(app_handle: tauri::AppHandle) -> Result<(), Str
}
// VPN commands
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct VpnDependencyStatus {
is_available: bool,
requires_external_install: bool,
missing_binary: bool,
missing_windows_adapter: bool,
dependency_check_failed: bool,
}
#[tauri::command]
async fn get_vpn_dependency_status(vpn_type: vpn::VpnType) -> Result<VpnDependencyStatus, String> {
match vpn_type {
vpn::VpnType::WireGuard => Ok(VpnDependencyStatus {
is_available: true,
requires_external_install: false,
missing_binary: false,
missing_windows_adapter: false,
dependency_check_failed: false,
}),
vpn::VpnType::OpenVPN => {
let status = crate::vpn::openvpn_socks5::OpenVpnSocks5Server::dependency_status();
let is_available =
status.binary_found && !status.missing_windows_adapter && !status.dependency_check_failed;
Ok(VpnDependencyStatus {
is_available,
requires_external_install: true,
missing_binary: !status.binary_found,
missing_windows_adapter: status.missing_windows_adapter,
dependency_check_failed: status.dependency_check_failed,
})
}
}
}
#[tauri::command]
async fn import_vpn_config(
content: String,
@@ -985,45 +978,81 @@ async fn check_vpn_validity(
.unwrap_or_default()
.as_secs();
// Start a temporary VPN worker to send real traffic
let had_existing_worker = vpn_worker_storage::find_vpn_worker_by_vpn_id(&vpn_id).is_some();
let vpn_worker = vpn_worker_runner::start_vpn_worker(&vpn_id)
.await
.map_err(|e| format!("Failed to start VPN worker: {e}"))?;
let socks_url = format!("socks5://127.0.0.1:{}", vpn_worker.local_port.unwrap_or(0));
let socks_url = format!(
"socks5://127.0.0.1:{}",
vpn_worker.local_port.unwrap_or_default()
);
// Fetch public IP through the VPN SOCKS5 proxy
let result = match ip_utils::fetch_public_ip(Some(&socks_url)).await {
Ok(ip) => {
let (city, country, country_code) =
crate::proxy_manager::ProxyManager::get_ip_geolocation(&ip)
.await
.unwrap_or_default();
crate::proxy_manager::ProxyCheckResult {
ip,
city,
country,
country_code,
timestamp: now,
is_valid: true,
}
}
Err(e) => {
log::warn!("VPN check failed to fetch public IP: {e}");
crate::proxy_manager::ProxyCheckResult {
ip: String::new(),
city: None,
country: None,
country_code: None,
timestamp: now,
is_valid: false,
let local_proxy = crate::proxy_runner::start_proxy_process(Some(socks_url), None)
.await
.map_err(|error| error.to_string());
let local_proxy = match local_proxy {
Ok(proxy) => proxy,
Err(error_message) => {
if !had_existing_worker {
let _ = vpn_worker_runner::stop_vpn_worker(&vpn_worker.id).await;
}
return Err(format!("Failed to start validation proxy: {error_message}"));
}
};
// Stop the temporary VPN worker
let _ = vpn_worker_runner::stop_vpn_worker(&vpn_worker.id).await;
let local_proxy_url = format!(
"http://127.0.0.1:{}",
local_proxy.local_port.unwrap_or_default()
);
let mut result = None;
for attempt in 0..3 {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
match ip_utils::fetch_public_ip(Some(&local_proxy_url)).await {
Ok(ip) => {
let (city, country, country_code) =
crate::proxy_manager::ProxyManager::get_ip_geolocation(&ip)
.await
.unwrap_or_default();
result = Some(crate::proxy_manager::ProxyCheckResult {
ip,
city,
country,
country_code,
timestamp: now,
is_valid: true,
});
break;
}
Err(error) => {
log::warn!(
"VPN validation attempt {} failed to fetch public IP through donut-proxy: {}",
attempt + 1,
error
);
}
}
}
let _ = crate::proxy_runner::stop_proxy_process(&local_proxy.id).await;
if !had_existing_worker {
let _ = vpn_worker_runner::stop_vpn_worker(&vpn_worker.id).await;
}
let result = result.unwrap_or(crate::proxy_manager::ProxyCheckResult {
ip: String::new(),
city: None,
country: None,
country_code: None,
timestamp: now,
is_valid: false,
});
Ok(result)
}
@@ -1116,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,
@@ -1132,6 +1162,7 @@ async fn generate_sample_fingerprint(
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
};
if browser == "camoufox" {
@@ -1462,6 +1493,17 @@ pub fn run() {
}
});
// DNS blocklist refresh task (every 12 hours)
tauri::async_runtime::spawn(async move {
let manager = dns_blocklist::BlocklistManager::instance();
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
interval.tick().await; // Skip the immediate first tick
loop {
interval.tick().await;
manager.refresh_all_stale().await;
}
});
tauri::async_runtime::spawn(async move {
let updater = app_auto_updater::AppAutoUpdater::instance();
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60));
@@ -1804,7 +1846,9 @@ 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,
kill_browser_profile,
rename_profile,
@@ -1816,6 +1860,7 @@ pub fn run() {
get_table_sorting_settings,
save_table_sorting_settings,
get_system_language,
get_system_info,
dismiss_window_resize_warning,
get_window_resize_warning_dismissed,
clear_all_version_cache_and_refetch,
@@ -1842,7 +1887,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,
@@ -1917,6 +1961,7 @@ pub fn run() {
add_mcp_to_claude_code,
remove_mcp_from_claude_code,
// VPN commands
get_vpn_dependency_status,
import_vpn_config,
list_vpn_configs,
get_vpn_config,
@@ -1951,6 +1996,9 @@ pub fn run() {
synchronizer::stop_sync_session,
synchronizer::remove_sync_follower,
synchronizer::get_sync_sessions,
// DNS blocklist commands
dns_blocklist::get_dns_blocklist_cache_status,
dns_blocklist::refresh_dns_blocklists,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
+177 -109
View File
@@ -26,6 +26,7 @@ use crate::settings_manager::SettingsManager;
use crate::wayfern_terms::WayfernTermsManager;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpTool {
pub name: String,
pub description: String,
@@ -507,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"
@@ -538,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)"
@@ -712,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": {
@@ -740,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 {
@@ -788,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"]
@@ -1008,6 +999,36 @@ impl McpServer {
"required": ["profile_id", "rules"]
}),
},
McpTool {
name: "update_profile_dns_blocklist".to_string(),
description:
"Update the DNS blocklist level for a profile. Blocks ads, trackers, and malware domains at the proxy level."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the profile to update"
},
"level": {
"type": "string",
"enum": ["none", "light", "normal", "pro", "pro_plus", "ultimate"],
"description": "DNS blocklist level. 'none' disables blocking."
}
},
"required": ["profile_id", "level"]
}),
},
McpTool {
name: "get_dns_blocklist_status".to_string(),
description: "Get the cache status of all DNS blocklist tiers including entry counts and freshness.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
McpTool {
name: "list_extensions".to_string(),
description: "List all managed browser extensions. Requires Pro subscription.".to_string(),
@@ -1481,6 +1502,9 @@ impl McpServer {
.handle_update_profile_proxy_bypass_rules(&arguments)
.await
}
// DNS blocklist management
"update_profile_dns_blocklist" => self.handle_update_profile_dns_blocklist(&arguments).await,
"get_dns_blocklist_status" => self.handle_get_dns_blocklist_status().await,
// Extension management
"list_extensions" => self.handle_list_extensions().await,
"list_extension_groups" => self.handle_list_extension_groups().await,
@@ -1775,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())
@@ -1804,7 +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 {
@@ -1872,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
@@ -2326,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",
@@ -2482,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": [{
@@ -3118,6 +3131,61 @@ impl McpServer {
}))
}
async fn handle_update_profile_dns_blocklist(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let level = arguments
.get("level")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing level".to_string(),
})?;
let dns_blocklist = if level == "none" {
None
} else {
Some(level.to_string())
};
let profile = ProfileManager::instance()
.update_profile_dns_blocklist(profile_id, dns_blocklist)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update DNS blocklist: {e}"),
})?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!(
"DNS blocklist updated for profile '{}': {}",
profile.name,
level
)
}]
}))
}
async fn handle_get_dns_blocklist_status(&self) -> Result<serde_json::Value, McpError> {
let statuses = crate::dns_blocklist::BlocklistManager::get_cache_status();
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": serde_json::to_string_pretty(&statuses).unwrap_or_default()
}]
}))
}
async fn handle_list_extensions(&self) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
+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(),
)
}
}
+146
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,
@@ -50,11 +70,15 @@ impl ProfileManager {
wayfern_config: Option<WayfernConfig>,
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 {
@@ -141,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(),
@@ -158,6 +183,7 @@ impl ProfileManager {
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
};
match self
@@ -240,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(),
@@ -257,6 +284,7 @@ impl ProfileManager {
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
};
match self
@@ -293,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(),
@@ -310,6 +339,7 @@ impl ProfileManager {
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
dns_blocklist,
};
// Save profile info
@@ -735,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,
@@ -760,6 +819,30 @@ impl ProfileManager {
Ok(profile)
}
pub fn update_profile_dns_blocklist(
&self,
profile_id: &str,
dns_blocklist: 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.dns_blocklist = dns_blocklist;
self.save_profile(&profile)?;
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
}
pub fn delete_multiple_profiles(
&self,
app_handle: &tauri::AppHandle,
@@ -885,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,
@@ -902,6 +986,7 @@ impl ProfileManager {
proxy_bypass_rules: source.proxy_bypass_rules,
created_by_id: None,
created_by_email: None,
dns_blocklist: source.dns_blocklist,
};
self.save_profile(&new_profile)?;
@@ -1941,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)]
@@ -1957,6 +2072,8 @@ pub async fn create_browser_profile_with_group(
wayfern_config: Option<WayfernConfig>,
group_id: Option<String>,
ephemeral: bool,
dns_blocklist: Option<String>,
launch_hook: Option<String>,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
@@ -1972,6 +2089,8 @@ pub async fn create_browser_profile_with_group(
wayfern_config,
group_id,
ephemeral,
dns_blocklist,
launch_hook,
)
.await
.map_err(|e| format!("Failed to create profile: {e}"))
@@ -2035,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,
@@ -2047,6 +2178,17 @@ pub fn update_profile_proxy_bypass_rules(
.map_err(|e| format!("Failed to update proxy bypass rules: {e}"))
}
#[tauri::command]
pub fn update_profile_dns_blocklist(
profile_id: String,
dns_blocklist: Option<String>,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
.update_profile_dns_blocklist(&profile_id, dns_blocklist)
.map_err(|e| format!("Failed to update DNS blocklist: {e}"))
}
#[tauri::command]
pub async fn check_browser_status(
app_handle: tauri::AppHandle,
@@ -2085,6 +2227,8 @@ pub async fn create_browser_profile_new(
wayfern_config: Option<WayfernConfig>,
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()
@@ -2112,6 +2256,8 @@ pub async fn create_browser_profile_new(
wayfern_config,
group_id,
ephemeral.unwrap_or(false),
dns_blocklist,
launch_hook,
)
.await
}
+5 -1
View File
@@ -21,7 +21,7 @@ pub enum SyncMode {
Encrypted,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct BrowserProfile {
pub id: uuid::Uuid,
pub name: String,
@@ -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>,
@@ -65,6 +67,8 @@ pub struct BrowserProfile {
pub created_by_id: Option<String>,
#[serde(default)]
pub created_by_email: Option<String>,
#[serde(default)]
pub dns_blocklist: Option<String>,
}
pub fn default_release_type() -> String {
+6
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(),
@@ -582,6 +583,7 @@ impl ProfileImporter {
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
};
match self
@@ -643,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(),
@@ -660,6 +663,7 @@ impl ProfileImporter {
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
};
match self
@@ -692,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(),
@@ -709,6 +714,7 @@ impl ProfileImporter {
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
};
self.profile_manager.save_profile(&profile)?;
+127 -215
View File
@@ -77,6 +77,7 @@ pub struct ProxyInfo {
pub local_port: u16,
// Optional profile ID to which this proxy instance is logically tied
pub profile_id: Option<String>,
pub blocklist_file: Option<String>,
}
// Proxy check result cache
@@ -144,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() {
@@ -1065,20 +1062,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}"))?;
@@ -1086,33 +1076,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}"))?;
@@ -1178,7 +1174,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()
@@ -1209,136 +1205,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();
@@ -1675,6 +1541,7 @@ impl ProxyManager {
browser_pid: u32,
profile_id: Option<&str>,
bypass_rules: Vec<String>,
blocklist_file: Option<String>,
) -> Result<ProxySettings, String> {
if let Some(name) = profile_id {
// Check if we have an active proxy recorded for this profile
@@ -1802,6 +1669,11 @@ impl ProxyManager {
proxy_cmd = proxy_cmd.arg("--bypass-rules").arg(rules_json);
}
// Add blocklist file path if provided
if let Some(ref path) = blocklist_file {
proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path);
}
// Execute the command and wait for it to complete
// The donut-proxy binary should start the worker and then exit
let output = proxy_cmd
@@ -1847,6 +1719,7 @@ impl ProxyManager {
.unwrap_or_else(|| "DIRECT".to_string()),
local_port,
profile_id: profile_id.map(|s| s.to_string()),
blocklist_file: blocklist_file.clone(),
};
// Wait for the local proxy port to be ready to accept connections
@@ -2231,6 +2104,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>> {
@@ -2345,6 +2220,7 @@ mod tests {
upstream_type: "http".to_string(),
local_port: (8000 + i) as u16,
profile_id: None,
blocklist_file: None,
};
// Add proxy
@@ -2671,6 +2547,7 @@ mod tests {
upstream_type: "http".to_string(),
local_port: port,
profile_id: profile_id.map(|s| s.to_string()),
blocklist_file: None,
}
}
@@ -2898,6 +2775,7 @@ mod tests {
pid: Some(live_pid),
profile_id: None,
bypass_rules: Vec::new(),
blocklist_file: None,
};
let dead_config = ProxyConfig {
id: dead_id.clone(),
@@ -2908,6 +2786,7 @@ mod tests {
pid: Some(dead_pid),
profile_id: None,
bypass_rules: Vec::new(),
blocklist_file: None,
};
save_proxy_config(&live_config).unwrap();
@@ -2946,6 +2825,7 @@ mod tests {
pid: Some(12345),
profile_id: Some("prof_abc".to_string()),
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
blocklist_file: None,
};
// Save
@@ -3064,6 +2944,7 @@ mod tests {
upstream_type: "http".to_string(),
local_port: 9201,
profile_id: Some("profile_alpha".to_string()),
blocklist_file: None,
};
let info_b = ProxyInfo {
id: "px_shared_b".to_string(),
@@ -3073,6 +2954,7 @@ mod tests {
upstream_type: "http".to_string(),
local_port: 9202,
profile_id: Some("profile_beta".to_string()),
blocklist_file: None,
};
pm.insert_active_proxy(3001, info_a);
@@ -3260,6 +3142,7 @@ mod tests {
pid: Some(dead_pid),
profile_id: None,
bypass_rules: Vec::new(),
blocklist_file: None,
};
save_proxy_config(&config).unwrap();
@@ -3432,6 +3315,7 @@ mod tests {
upstream_type: ptype.to_string(),
local_port: 9300 + i as u16,
profile_id: Some(format!("profile_{ptype}")),
blocklist_file: None,
};
pm.insert_active_proxy(4000 + i as u32, info);
}
@@ -3651,74 +3535,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"));
}
}
+154 -3
View File
@@ -2,17 +2,166 @@ use crate::proxy_storage::{
delete_proxy_config, generate_proxy_id, get_proxy_config, is_process_running, list_proxy_configs,
save_proxy_config, ProxyConfig,
};
use std::path::{Path, PathBuf};
use std::process::Stdio;
lazy_static::lazy_static! {
static ref PROXY_PROCESSES: std::sync::Mutex<std::collections::HashMap<String, u32>> =
std::sync::Mutex::new(std::collections::HashMap::new());
}
fn target_binary_name(base_name: &str) -> Option<String> {
let target = std::env::var("TARGET").ok()?;
#[cfg(windows)]
{
Some(format!("{base_name}-{target}.exe"))
}
#[cfg(not(windows))]
{
Some(format!("{base_name}-{target}"))
}
}
fn unsuffixed_binary_name(base_name: &str) -> String {
#[cfg(windows)]
{
match base_name {
"donut-proxy" => "donut-proxy.exe".to_string(),
"donut-daemon" => "donut-daemon.exe".to_string(),
_ => String::new(),
}
}
#[cfg(not(windows))]
{
base_name.to_string()
}
}
fn binary_matches_prefix(path: &Path, base_name: &str) -> bool {
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
#[cfg(windows)]
{
file_name.starts_with(&format!("{base_name}-")) && file_name.ends_with(".exe")
}
#[cfg(not(windows))]
{
file_name.starts_with(&format!("{base_name}-"))
}
}
fn push_candidate_dir(dirs: &mut Vec<PathBuf>, dir: Option<PathBuf>) {
if let Some(dir) = dir {
if !dirs.iter().any(|existing| existing == &dir) {
dirs.push(dir);
}
}
}
pub(crate) fn find_sidecar_executable(
base_name: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let current_exe = std::env::current_exe()?;
let current_dir = current_exe
.parent()
.ok_or("Failed to get parent directory of current executable")?;
if current_exe
.file_stem()
.and_then(|stem| stem.to_str())
.is_some_and(|stem| stem == base_name)
{
return Ok(current_exe);
}
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut search_dirs = Vec::new();
push_candidate_dir(&mut search_dirs, Some(current_dir.to_path_buf()));
push_candidate_dir(
&mut search_dirs,
current_dir.parent().map(std::path::Path::to_path_buf),
);
push_candidate_dir(
&mut search_dirs,
current_dir
.parent()
.and_then(|parent| parent.parent())
.map(Path::to_path_buf),
);
push_candidate_dir(&mut search_dirs, Some(current_dir.join("binaries")));
push_candidate_dir(
&mut search_dirs,
current_dir.parent().map(|parent| parent.join("binaries")),
);
push_candidate_dir(
&mut search_dirs,
current_dir
.parent()
.and_then(|parent| parent.parent())
.map(|parent| parent.join("binaries")),
);
push_candidate_dir(&mut search_dirs, Some(manifest_dir.join("binaries")));
push_candidate_dir(
&mut search_dirs,
Some(manifest_dir.join("target").join("debug")),
);
push_candidate_dir(
&mut search_dirs,
Some(manifest_dir.join("target").join("release")),
);
let mut exact_names = vec![unsuffixed_binary_name(base_name)];
if let Some(target_name) = target_binary_name(base_name) {
exact_names.push(target_name);
}
for dir in &search_dirs {
for name in &exact_names {
if name.is_empty() {
continue;
}
let candidate = dir.join(name);
if candidate.exists() {
return Ok(candidate);
}
}
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && binary_matches_prefix(&path, base_name) {
return Ok(path);
}
}
}
}
Err(
format!(
"Failed to locate '{}' executable. Searched in: {}",
base_name,
search_dirs
.iter()
.map(|dir| dir.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)
.into(),
)
}
pub async fn start_proxy_process(
upstream_url: Option<String>,
port: Option<u16>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
start_proxy_process_with_profile(upstream_url, port, None, Vec::new()).await
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None).await
}
pub async fn start_proxy_process_with_profile(
@@ -20,6 +169,7 @@ pub async fn start_proxy_process_with_profile(
port: Option<u16>,
profile_id: Option<String>,
bypass_rules: Vec<String>,
blocklist_file: Option<String>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
let id = generate_proxy_id();
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
@@ -33,7 +183,8 @@ pub async fn start_proxy_process_with_profile(
let config = ProxyConfig::new(id.clone(), upstream, Some(local_port))
.with_profile_id(profile_id.clone())
.with_bypass_rules(bypass_rules);
.with_bypass_rules(bypass_rules)
.with_blocklist_file(blocklist_file);
save_proxy_config(&config)?;
// Log profile_id for debugging
@@ -45,7 +196,7 @@ pub async fn start_proxy_process_with_profile(
// Spawn proxy worker process in the background using std::process::Command
// This ensures proper process detachment on Unix systems
let exe = std::env::current_exe()?;
let exe = find_sidecar_executable("donut-proxy")?;
#[cfg(unix)]
{
+217 -7
View File
@@ -7,6 +7,7 @@ use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use regex_lite::Regex;
use std::collections::HashSet;
use std::convert::Infallible;
use std::io;
use std::net::SocketAddr;
@@ -51,6 +52,58 @@ impl BypassMatcher {
}
}
#[derive(Clone)]
pub struct BlocklistMatcher {
domains: Arc<HashSet<String>>,
}
impl Default for BlocklistMatcher {
fn default() -> Self {
Self::new()
}
}
impl BlocklistMatcher {
pub fn new() -> Self {
Self {
domains: Arc::new(HashSet::new()),
}
}
pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let domains: HashSet<String> = content
.lines()
.filter(|line| !line.starts_with('#') && !line.trim().is_empty())
.map(|line| line.trim().to_lowercase())
.collect();
log::info!("[blocklist] Loaded {} domains from {}", domains.len(), path);
Ok(Self {
domains: Arc::new(domains),
})
}
pub fn is_blocked(&self, host: &str) -> bool {
if self.domains.is_empty() {
return false;
}
let host_lower = host.to_lowercase();
// Exact match
if self.domains.contains(host_lower.as_str()) {
return true;
}
// Suffix matching: check parent domains (like uBlock)
let mut start = 0;
while let Some(dot_pos) = host_lower[start..].find('.') {
start += dot_pos + 1;
if self.domains.contains(&host_lower[start..]) {
return true;
}
}
false
}
}
/// Wrapper stream that counts bytes read and written
struct CountingStream<S> {
inner: S,
@@ -167,20 +220,22 @@ async fn handle_request(
req: Request<hyper::body::Incoming>,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
blocklist_matcher: BlocklistMatcher,
) -> Result<Response<Full<Bytes>>, Infallible> {
// Handle CONNECT method for HTTPS tunneling
if req.method() == Method::CONNECT {
return handle_connect(req, upstream_url, bypass_matcher).await;
return handle_connect(req, upstream_url, bypass_matcher, blocklist_matcher).await;
}
// Handle regular HTTP requests
handle_http(req, upstream_url, bypass_matcher).await
handle_http(req, upstream_url, bypass_matcher, blocklist_matcher).await
}
async fn handle_connect(
req: Request<hyper::body::Incoming>,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
blocklist_matcher: BlocklistMatcher,
) -> Result<Response<Full<Bytes>>, Infallible> {
let authority = req.uri().authority().cloned();
@@ -196,6 +251,14 @@ async fn handle_connect(
(&target_addr[..], 443)
};
// Block if domain is in the DNS blocklist (before any connection)
if blocklist_matcher.is_blocked(target_host) {
log::debug!("[blocklist] Blocked CONNECT to {}", target_host);
let mut response = Response::new(Full::new(Bytes::from("Blocked by DNS blocklist")));
*response.status_mut() = StatusCode::FORBIDDEN;
return Ok(response);
}
// If no upstream proxy, or bypass rule matches, connect directly
if upstream_url.is_none()
|| upstream_url
@@ -711,6 +774,7 @@ async fn handle_http(
req: Request<hyper::body::Incoming>,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
blocklist_matcher: BlocklistMatcher,
) -> Result<Response<Full<Bytes>>, Infallible> {
// Extract domain for traffic tracking
let domain = req
@@ -719,6 +783,14 @@ async fn handle_http(
.map(|h| h.to_string())
.unwrap_or_else(|| "unknown".to_string());
// Block if domain is in the DNS blocklist (before any connection)
if blocklist_matcher.is_blocked(&domain) {
log::debug!("[blocklist] Blocked HTTP request to {}", domain);
let mut response = Response::new(Full::new(Bytes::from("Blocked by DNS blocklist")));
*response.status_mut() = StatusCode::FORBIDDEN;
return Ok(response);
}
log::error!(
"DEBUG: Handling HTTP request: {} {} (host: {:?})",
req.method(),
@@ -888,6 +960,7 @@ pub async fn handle_proxy_connection(
mut stream: tokio::net::TcpStream,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
blocklist_matcher: BlocklistMatcher,
) {
let _ = stream.set_nodelay(true);
@@ -942,8 +1015,14 @@ pub async fn handle_proxy_connection(
}
}
let _ =
handle_connect_from_buffer(stream, full_request, upstream_url, bypass_matcher).await;
let _ = handle_connect_from_buffer(
stream,
full_request,
upstream_url,
bypass_matcher,
blocklist_matcher,
)
.await;
return;
}
@@ -955,8 +1034,14 @@ pub async fn handle_proxy_connection(
inner: stream,
};
let io = TokioIo::new(prepended_reader);
let service =
service_fn(move |req| handle_request(req, upstream_url.clone(), bypass_matcher.clone()));
let service = service_fn(move |req| {
handle_request(
req,
upstream_url.clone(),
bypass_matcher.clone(),
blocklist_matcher.clone(),
)
});
let _ = http1::Builder::new().serve_connection(io, service).await;
}
@@ -1128,6 +1213,17 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
});
let bypass_matcher = BypassMatcher::new(&config.bypass_rules);
let blocklist_matcher = if let Some(ref path) = config.blocklist_file {
match BlocklistMatcher::from_file(path) {
Ok(m) => m,
Err(e) => {
log::error!("[blocklist] Failed to load from {}: {}", path, e);
BlocklistMatcher::new()
}
}
} else {
BlocklistMatcher::new()
};
// Keep the runtime alive with an infinite loop
// This ensures the process doesn't exit even if there are no active connections
@@ -1136,8 +1232,9 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
Ok((stream, _peer_addr)) => {
let upstream = upstream_url.clone();
let matcher = bypass_matcher.clone();
let blocker = blocklist_matcher.clone();
tokio::task::spawn(async move {
handle_proxy_connection(stream, upstream, matcher).await;
handle_proxy_connection(stream, upstream, matcher, blocker).await;
});
}
Err(e) => {
@@ -1155,6 +1252,7 @@ async fn handle_connect_from_buffer(
request_buffer: Vec<u8>,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
blocklist_matcher: BlocklistMatcher,
) -> Result<(), Box<dyn std::error::Error>> {
// Parse the CONNECT request from the buffer
let request_str = String::from_utf8_lossy(&request_buffer);
@@ -1185,6 +1283,15 @@ async fn handle_connect_from_buffer(
(target, 443)
};
// Block if domain is in the DNS blocklist (before any connection)
if blocklist_matcher.is_blocked(target_host) {
log::debug!("[blocklist] Blocked CONNECT tunnel to {}", target_host);
let _ = client_stream
.write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 24\r\n\r\nBlocked by DNS blocklist")
.await;
return Ok(());
}
// Record domain access in traffic tracker
let domain = target_host.to_string();
if let Some(tracker) = get_traffic_tracker() {
@@ -1362,3 +1469,106 @@ async fn handle_connect_from_buffer(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_blocklist_exact_match() {
let mut matcher = BlocklistMatcher::new();
let mut domains = HashSet::new();
domains.insert("example.com".to_string());
domains.insert("tracker.net".to_string());
matcher.domains = Arc::new(domains);
assert!(matcher.is_blocked("example.com"));
assert!(matcher.is_blocked("tracker.net"));
assert!(!matcher.is_blocked("safe.com"));
}
#[test]
fn test_blocklist_subdomain_match() {
let mut matcher = BlocklistMatcher::new();
let mut domains = HashSet::new();
domains.insert("example.com".to_string());
matcher.domains = Arc::new(domains);
assert!(matcher.is_blocked("foo.example.com"));
assert!(matcher.is_blocked("bar.baz.example.com"));
assert!(matcher.is_blocked("a.b.c.example.com"));
}
#[test]
fn test_blocklist_no_false_positives() {
let mut matcher = BlocklistMatcher::new();
let mut domains = HashSet::new();
domains.insert("example.com".to_string());
matcher.domains = Arc::new(domains);
// "notexample.com" should NOT match "example.com"
assert!(!matcher.is_blocked("notexample.com"));
assert!(!matcher.is_blocked("myexample.com"));
// But subdomain should
assert!(matcher.is_blocked("sub.example.com"));
}
#[test]
fn test_blocklist_empty_blocks_nothing() {
let matcher = BlocklistMatcher::new();
assert!(!matcher.is_blocked("anything.com"));
assert!(!matcher.is_blocked("example.com"));
}
#[test]
fn test_blocklist_case_insensitive() {
let mut matcher = BlocklistMatcher::new();
let mut domains = HashSet::new();
domains.insert("example.com".to_string());
matcher.domains = Arc::new(domains);
assert!(matcher.is_blocked("EXAMPLE.COM"));
assert!(matcher.is_blocked("Example.Com"));
assert!(matcher.is_blocked("FOO.EXAMPLE.COM"));
}
#[test]
fn test_blocklist_from_file() {
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
writeln!(tmpfile, "# This is a comment").unwrap();
writeln!(tmpfile).unwrap();
writeln!(tmpfile, "tracker.example.com").unwrap();
writeln!(tmpfile, "ads.network.com").unwrap();
writeln!(tmpfile, "# Another comment").unwrap();
writeln!(tmpfile, "malware.site").unwrap();
tmpfile.flush().unwrap();
let matcher = BlocklistMatcher::from_file(tmpfile.path().to_str().unwrap()).unwrap();
assert!(matcher.is_blocked("tracker.example.com"));
assert!(matcher.is_blocked("ads.network.com"));
assert!(matcher.is_blocked("malware.site"));
assert!(matcher.is_blocked("sub.malware.site"));
assert!(!matcher.is_blocked("safe.com"));
// Comments and empty lines should be skipped: 3 domains loaded
assert_eq!(matcher.domains.len(), 3);
}
#[test]
fn test_blocklist_comments_skipped() {
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
writeln!(tmpfile, "# Title: HaGeZi's Light DNS Blocklist").unwrap();
writeln!(tmpfile, "# Description: test").unwrap();
writeln!(tmpfile, "# Version: 2026.0330.0928.01").unwrap();
writeln!(tmpfile).unwrap();
writeln!(tmpfile, "domain1.com").unwrap();
writeln!(tmpfile, "domain2.com").unwrap();
tmpfile.flush().unwrap();
let matcher = BlocklistMatcher::from_file(tmpfile.path().to_str().unwrap()).unwrap();
assert_eq!(matcher.domains.len(), 2);
assert!(matcher.is_blocked("domain1.com"));
assert!(matcher.is_blocked("domain2.com"));
}
}
+8
View File
@@ -14,6 +14,8 @@ pub struct ProxyConfig {
pub profile_id: Option<String>,
#[serde(default)]
pub bypass_rules: Vec<String>,
#[serde(default)]
pub blocklist_file: Option<String>,
}
impl ProxyConfig {
@@ -27,6 +29,7 @@ impl ProxyConfig {
pid: None,
profile_id: None,
bypass_rules: Vec::new(),
blocklist_file: None,
}
}
@@ -39,6 +42,11 @@ impl ProxyConfig {
self.bypass_rules = bypass_rules;
self
}
pub fn with_blocklist_file(mut self, blocklist_file: Option<String>) -> Self {
self.blocklist_file = blocklist_file;
self
}
}
pub fn get_storage_dir() -> PathBuf {
+36
View File
@@ -945,6 +945,42 @@ pub fn get_system_language() -> String {
.unwrap_or_else(|| "en".to_string())
}
#[derive(Debug, Serialize, Clone)]
pub struct SystemInfo {
pub app_version: String,
pub os: String,
pub arch: String,
pub portable: bool,
}
#[tauri::command]
pub fn get_system_info() -> SystemInfo {
let os = if cfg!(target_os = "macos") {
"macOS"
} else if cfg!(target_os = "windows") {
"Windows"
} else if cfg!(target_os = "linux") {
"Linux"
} else {
"Unknown"
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
"unknown"
};
SystemInfo {
app_version: crate::app_auto_updater::AppAutoUpdater::get_current_version(),
os: os.to_string(),
arch: arch.to_string(),
portable: crate::app_dirs::is_portable(),
}
}
// Global singleton instance
lazy_static::lazy_static! {
static ref SETTINGS_MANAGER: SettingsManager = SettingsManager::new();
+1
View File
@@ -793,6 +793,7 @@ impl SyncEngine {
let mut sanitized = profile.clone();
sanitized.process_id = None;
sanitized.last_launch = None;
sanitized.last_sync = None; // Avoid triggering sync loop on timestamp change
let json = serde_json::to_string_pretty(&sanitized)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?;
+134 -2
View File
@@ -8,6 +8,7 @@ use std::path::Path;
use std::time::SystemTime;
use super::types::{SyncError, SyncResult};
use crate::profile::types::BrowserProfile;
/// Default exclude patterns for volatile browser profile files.
/// Patterns use `**/` prefix to match at any directory depth, since the sync
@@ -209,6 +210,39 @@ fn hash_file(path: &Path) -> Result<Option<String>, SyncError> {
Ok(Some(hasher.finalize().to_hex().to_string()))
}
/// Compute blake3 hash of metadata.json after sanitizing volatile fields.
/// This prevents infinite sync loops where updating last_sync triggers a new sync.
fn hash_sanitized_metadata(path: &Path) -> Result<Option<String>, SyncError> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(SyncError::IoError(format!(
"Failed to read metadata at {}: {e}",
path.display()
)));
}
};
let mut profile: BrowserProfile = serde_json::from_str(&content).map_err(|e| {
SyncError::SerializationError(format!("Failed to parse metadata for hashing: {e}"))
})?;
// Sanitize volatile fields that should not trigger a re-sync
profile.last_sync = None;
profile.process_id = None;
profile.last_launch = None;
let sanitized_json = serde_json::to_string(&profile).map_err(|e| {
SyncError::SerializationError(format!("Failed to serialize sanitized metadata: {e}"))
})?;
let mut hasher = blake3::Hasher::new();
hasher.update(sanitized_json.as_bytes());
Ok(Some(hasher.finalize().to_hex().to_string()))
}
/// Get mtime as unix timestamp
/// Returns None if the file doesn't exist (was deleted)
fn get_mtime(path: &Path) -> Result<Option<i64>, SyncError> {
@@ -324,7 +358,19 @@ pub fn generate_manifest(
*max_mtime = (*max_mtime).max(mtime);
// Check cache for existing hash
let hash = if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
let hash = if relative_path == "metadata.json" {
// Special case: sanitize metadata.json before hashing to prevent sync loops
match hash_sanitized_metadata(&path)? {
Some(computed_hash) => computed_hash,
None => {
log::debug!(
"File disappeared during manifest generation, skipping: {}",
path.display()
);
continue;
}
}
} else if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
cached_hash.to_string()
} else {
match hash_file(&path)? {
@@ -592,7 +638,12 @@ mod tests {
fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap();
// metadata.json at root
fs::write(profile_dir.join("metadata.json"), "keep").unwrap();
let profile = BrowserProfile::default();
fs::write(
profile_dir.join("metadata.json"),
serde_json::to_string(&profile).unwrap(),
)
.unwrap();
let mut cache = HashCache::default();
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
@@ -800,4 +851,85 @@ mod tests {
assert!(diff.files_to_delete_remote.is_empty());
assert!(diff.files_to_delete_local.is_empty());
}
#[test]
fn test_generate_manifest_sanitizes_metadata() {
let temp_dir = TempDir::new().unwrap();
let profile_dir = temp_dir.path().join("profile");
fs::create_dir_all(&profile_dir).unwrap();
let profile_id = uuid::Uuid::new_v4();
let metadata_path = profile_dir.join("metadata.json");
let profile = BrowserProfile {
id: profile_id,
name: "test-profile".to_string(),
last_sync: Some(100),
process_id: Some(1234),
..Default::default()
};
fs::write(&metadata_path, serde_json::to_string(&profile).unwrap()).unwrap();
let mut cache = HashCache::default();
let manifest1 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
let hash1 = manifest1
.files
.iter()
.find(|f| f.path == "metadata.json")
.unwrap()
.hash
.clone();
// Update volatile fields
let profile2 = BrowserProfile {
id: profile_id,
name: "test-profile".to_string(),
last_sync: Some(200),
process_id: Some(5678),
..Default::default()
};
fs::write(&metadata_path, serde_json::to_string(&profile2).unwrap()).unwrap();
let manifest2 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
let hash2 = manifest2
.files
.iter()
.find(|f| f.path == "metadata.json")
.unwrap()
.hash
.clone();
// Hash should be identical because volatile fields are sanitized
assert_eq!(
hash1, hash2,
"Metadata hash should be stable across last_sync/process_id updates"
);
// Change a non-volatile field
let profile3 = BrowserProfile {
id: profile_id,
name: "changed-name".to_string(),
last_sync: Some(200),
..Default::default()
};
fs::write(&metadata_path, serde_json::to_string(&profile3).unwrap()).unwrap();
let manifest3 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
let hash3 = manifest3
.files
.iter()
.find(|f| f.path == "metadata.json")
.unwrap()
.hash
.clone();
// Hash should be different because name changed
assert_ne!(
hash1, hash3,
"Metadata hash should change when non-volatile fields change"
);
}
}
+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;
}
+6 -2
View File
@@ -580,7 +580,9 @@ impl LiveTrafficTracker {
.profile_id
.clone()
.unwrap_or_else(|| self.proxy_id.clone());
let session_file = get_traffic_stats_dir().join(format!("{}.session.json", storage_key));
let storage_dir = get_traffic_stats_dir();
fs::create_dir_all(&storage_dir)?;
let session_file = storage_dir.join(format!("{}.session.json", storage_key));
// Write atomically using a temp file
let temp_file = session_file.with_extension("tmp");
@@ -761,9 +763,11 @@ impl LiveTrafficTracker {
.profile_id
.clone()
.unwrap_or_else(|| self.proxy_id.clone());
let storage_dir = get_traffic_stats_dir();
fs::create_dir_all(&storage_dir)?;
// Use file locking to prevent concurrent writes from multiple proxy processes
let lock_path = get_traffic_stats_dir().join(format!("{}.lock", storage_key));
let lock_path = storage_dir.join(format!("{}.lock", storage_key));
let _lock = match acquire_file_lock(&lock_path) {
Ok(lock) => lock,
Err(e) => {
+636 -64
View File
@@ -1,8 +1,24 @@
use super::config::{OpenVpnConfig, VpnError};
use std::path::PathBuf;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::{lookup_host, TcpListener, TcpSocket, TcpStream};
const OPENVPN_CONNECT_TIMEOUT_SECS: u64 = 90;
enum SocksTarget {
Address(SocketAddr),
Domain(String, u16),
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct OpenVpnDependencyStatus {
pub binary_found: bool,
pub missing_windows_adapter: bool,
pub dependency_check_failed: bool,
}
pub struct OpenVpnSocks5Server {
config: OpenVpnConfig,
@@ -14,7 +30,168 @@ impl OpenVpnSocks5Server {
Self { config, port }
}
fn find_openvpn_binary() -> Result<PathBuf, VpnError> {
fn read_log_tail(path: &Path, lines: usize) -> String {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.rev()
.take(lines)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n")
}
fn extract_vpn_ip(line: &str) -> Option<Ipv4Addr> {
for field in line.split(',') {
let trimmed = field.trim();
if let Ok(ip) = trimmed.parse::<Ipv4Addr>() {
if ip.is_private() && !ip.is_loopback() {
return Some(ip);
}
}
}
None
}
fn log_indicates_connected(log_content: &str) -> bool {
log_content.contains("Initialization Sequence Completed")
}
fn log_indicates_failure(log_content: &str) -> bool {
log_content.contains("AUTH_FAILED")
|| log_content.contains("Exiting due to fatal error")
|| log_content.contains("Fatal error")
|| log_content.contains("Options error")
|| log_content.contains("Exiting")
}
fn has_config_directive(config: &str, directive: &str) -> bool {
config.lines().any(|line| {
let trimmed = line.trim();
!trimmed.is_empty()
&& !trimmed.starts_with('#')
&& !trimmed.starts_with(';')
&& trimmed.starts_with(directive)
})
}
fn strip_config_directive(config: &str, directive: &str) -> String {
config
.lines()
.filter(|line| {
let trimmed = line.trim();
trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with(';')
|| !trimmed.starts_with(directive)
})
.collect::<Vec<_>>()
.join("\n")
}
fn build_runtime_config(&self) -> String {
let mut runtime_config = self.config.raw_config.clone();
runtime_config = Self::strip_config_directive(&runtime_config, "redirect-gateway");
runtime_config = Self::strip_config_directive(&runtime_config, "block-outside-dns");
runtime_config = Self::strip_config_directive(&runtime_config, "dhcp-option");
if !runtime_config.contains("pull-filter ignore \"redirect-gateway\"") {
runtime_config.push_str("\npull-filter ignore \"redirect-gateway\"\n");
}
if !runtime_config.contains("pull-filter ignore \"block-outside-dns\"") {
runtime_config.push_str("pull-filter ignore \"block-outside-dns\"\n");
}
if !runtime_config.contains("pull-filter ignore \"dhcp-option\"") {
runtime_config.push_str("pull-filter ignore \"dhcp-option\"\n");
}
if !Self::has_config_directive(&runtime_config, "route 0.0.0.0") {
runtime_config.push_str("\nroute 0.0.0.0 0.0.0.0 vpn_gateway 9999\n");
}
#[cfg(windows)]
{
if Self::has_config_directive(&runtime_config, "dev-node") {
runtime_config = runtime_config
.lines()
.filter(|line| {
let trimmed = line.trim();
trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with(';')
|| !trimmed.starts_with("dev-node")
})
.collect::<Vec<_>>()
.join("\n");
}
if !Self::has_config_directive(&runtime_config, "disable-dco") {
runtime_config.push_str("\ndisable-dco\n");
}
if self.config.dev_type.starts_with("tun")
&& !Self::has_config_directive(&runtime_config, "windows-driver")
{
runtime_config.push_str("\nwindows-driver wintun\n");
}
}
runtime_config
}
pub(crate) fn dependency_status() -> OpenVpnDependencyStatus {
let Ok(openvpn_bin) = Self::find_openvpn_binary() else {
return OpenVpnDependencyStatus {
binary_found: false,
missing_windows_adapter: false,
dependency_check_failed: false,
};
};
#[cfg(windows)]
{
match Self::windows_openvpn_has_adapter(&openvpn_bin) {
Ok(has_adapter) => OpenVpnDependencyStatus {
binary_found: true,
missing_windows_adapter: !has_adapter,
dependency_check_failed: false,
},
Err(_) => OpenVpnDependencyStatus {
binary_found: true,
missing_windows_adapter: false,
dependency_check_failed: true,
},
}
}
#[cfg(not(windows))]
{
let _ = openvpn_bin;
OpenVpnDependencyStatus {
binary_found: true,
missing_windows_adapter: false,
dependency_check_failed: false,
}
}
}
pub(crate) fn find_openvpn_binary() -> Result<PathBuf, VpnError> {
if let Ok(path) = std::env::var("DONUTBROWSER_OPENVPN_BIN") {
let path = PathBuf::from(path);
if path.exists() {
return Ok(path);
}
return Err(VpnError::Connection(format!(
"Configured OpenVPN binary does not exist: {}",
path.display()
)));
}
let locations = [
"/usr/sbin/openvpn",
"/usr/local/sbin/openvpn",
@@ -71,12 +248,300 @@ impl OpenVpnSocks5Server {
))
}
fn openvpn_supports_management(openvpn_bin: &Path) -> bool {
let mut command = Command::new(openvpn_bin);
command.arg("--version");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(CREATE_NO_WINDOW);
}
let Ok(output) = command.output() else {
return true;
};
let version_text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
!version_text.contains("enable_management=no")
}
#[cfg(windows)]
pub(crate) fn windows_openvpn_has_adapter(openvpn_bin: &Path) -> Result<bool, VpnError> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = Command::new(openvpn_bin)
.arg("--show-adapters")
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| VpnError::Connection(format!("Failed to inspect OpenVPN adapters: {e}")))?;
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
Ok(
text
.lines()
.map(str::trim)
.any(|line| !line.is_empty() && !line.starts_with("Available adapters")),
)
}
fn extract_vpn_ip_from_log(log_content: &str) -> Option<Ipv4Addr> {
for line in log_content.lines() {
if let Some(ip) = Self::extract_vpn_ip(line) {
return Some(ip);
}
if let Some(position) = line.find("ifconfig ") {
let after = &line[position + "ifconfig ".len()..];
if let Some(ip_str) = after
.split_whitespace()
.next()
.or_else(|| after.split(',').next())
{
if let Ok(ip) = ip_str.parse::<Ipv4Addr>() {
if ip.is_private() && !ip.is_loopback() {
return Some(ip);
}
}
}
}
}
None
}
async fn wait_for_openvpn_ready_via_management(
child: &mut std::process::Child,
mgmt_port: u16,
log_path: &Path,
) -> Result<Option<Ipv4Addr>, VpnError> {
let deadline =
tokio::time::Instant::now() + tokio::time::Duration::from_secs(OPENVPN_CONNECT_TIMEOUT_SECS);
let mgmt_stream = loop {
if tokio::time::Instant::now() >= deadline {
return Err(VpnError::Connection(format!(
"Timed out connecting to OpenVPN management interface. Last OpenVPN output:\n{}",
Self::read_log_tail(log_path, 20)
)));
}
if let Ok(Some(status)) = child.try_wait() {
return Err(VpnError::Connection(format!(
"OpenVPN exited (status: {}) before the tunnel was established. Last output:\n{}",
status,
Self::read_log_tail(log_path, 20)
)));
}
match TcpStream::connect(("127.0.0.1", mgmt_port)).await {
Ok(stream) => break stream,
Err(_) => tokio::time::sleep(tokio::time::Duration::from_millis(500)).await,
}
};
let (mgmt_reader, mut mgmt_writer) = mgmt_stream.into_split();
let _ = mgmt_writer.write_all(b"state on\nstate\n").await;
let mut lines = BufReader::new(mgmt_reader).lines();
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
interval.tick().await;
let mut vpn_ip = None;
loop {
if tokio::time::Instant::now() >= deadline {
return Err(VpnError::Connection(format!(
"Timed out waiting for OpenVPN to reach CONNECTED state. Last OpenVPN output:\n{}",
Self::read_log_tail(log_path, 20)
)));
}
if let Ok(Some(status)) = child.try_wait() {
return Err(VpnError::Connection(format!(
"OpenVPN exited (status: {}) before connecting. Last output:\n{}",
status,
Self::read_log_tail(log_path, 20)
)));
}
tokio::select! {
line_result = lines.next_line() => {
match line_result {
Ok(Some(line)) => {
if let Some(ip) = Self::extract_vpn_ip(&line) {
vpn_ip = Some(ip);
}
if line.contains(",CONNECTED,") {
break;
}
if line.contains("AUTH_FAILED") {
return Err(VpnError::Connection(format!(
"OpenVPN authentication failed. Last output:\n{}",
Self::read_log_tail(log_path, 20)
)));
}
if line.contains(",EXITING,") || line.contains(">FATAL:") {
return Err(VpnError::Connection(format!(
"OpenVPN is exiting. Last output:\n{}",
Self::read_log_tail(log_path, 20)
)));
}
}
Ok(None) => {
return Err(VpnError::Connection(format!(
"OpenVPN management connection closed before CONNECTED state. Last output:\n{}",
Self::read_log_tail(log_path, 20)
)));
}
Err(_) => {}
}
}
_ = interval.tick() => {
let _ = mgmt_writer.write_all(b"state\n").await;
let log_path = log_path.to_path_buf();
let log_content = tokio::task::spawn_blocking(move || std::fs::read_to_string(log_path))
.await
.ok()
.and_then(Result::ok);
if let Some(content) = log_content {
if Self::log_indicates_connected(&content) {
break;
}
}
}
}
}
if vpn_ip.is_none() {
if let Ok(log_content) = std::fs::read_to_string(log_path) {
vpn_ip = Self::extract_vpn_ip_from_log(&log_content);
}
}
Ok(vpn_ip)
}
async fn wait_for_openvpn_ready_via_log(
child: &mut std::process::Child,
log_path: &Path,
) -> Result<Option<Ipv4Addr>, VpnError> {
let deadline =
tokio::time::Instant::now() + tokio::time::Duration::from_secs(OPENVPN_CONNECT_TIMEOUT_SECS);
loop {
if tokio::time::Instant::now() >= deadline {
return Err(VpnError::Connection(format!(
"Timed out waiting for OpenVPN to connect. Last OpenVPN output:\n{}",
Self::read_log_tail(log_path, 40)
)));
}
if let Ok(Some(status)) = child.try_wait() {
return Err(VpnError::Connection(format!(
"OpenVPN exited (status: {}) before connecting. Last output:\n{}",
status,
Self::read_log_tail(log_path, 40)
)));
}
let log_path_buf = log_path.to_path_buf();
let log_content = tokio::task::spawn_blocking(move || std::fs::read_to_string(log_path_buf))
.await
.ok()
.and_then(Result::ok)
.unwrap_or_default();
if Self::log_indicates_connected(&log_content) {
return Ok(Self::extract_vpn_ip_from_log(&log_content));
}
if Self::log_indicates_failure(&log_content) {
return Err(VpnError::Connection(format!(
"OpenVPN reported a fatal error while connecting. Last output:\n{}",
Self::read_log_tail(log_path, 40)
)));
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
}
async fn connect_target(
target: SocksTarget,
vpn_bind_ip: Ipv4Addr,
) -> Result<(TcpStream, SocketAddr), Box<dyn std::error::Error + Send + Sync>> {
let mut addresses = match target {
SocksTarget::Address(addr) => vec![addr],
SocksTarget::Domain(host, port) => {
let mut resolved = lookup_host((host.as_str(), port))
.await?
.collect::<Vec<_>>();
resolved.sort_by_key(|addr| if addr.is_ipv4() { 0 } else { 1 });
resolved
}
};
if addresses.is_empty() {
return Err("No addresses resolved for SOCKS5 target".into());
}
let mut last_error = None;
for address in addresses.drain(..) {
let socket = if address.is_ipv4() {
let socket = TcpSocket::new_v4()?;
if !vpn_bind_ip.is_unspecified() {
socket.bind(SocketAddr::new(IpAddr::V4(vpn_bind_ip), 0))?;
}
socket
} else {
TcpSocket::new_v6()?
};
match socket.connect(address).await {
Ok(stream) => return Ok((stream, address)),
Err(error) => last_error = Some(error),
}
}
Err(
last_error
.map(|error| error.into())
.unwrap_or_else(|| "Failed to connect to any resolved SOCKS5 target".into()),
)
}
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
let openvpn_bin = Self::find_openvpn_binary()?;
let supports_management = Self::openvpn_supports_management(&openvpn_bin);
#[cfg(windows)]
if !Self::windows_openvpn_has_adapter(&openvpn_bin)? {
return Err(VpnError::Connection(
"OpenVPN requires a TAP/Wintun/ovpn-dco adapter on Windows, but none were found. Install or provision an adapter before connecting.".to_string(),
));
}
// Write config to temp file
let config_path = std::env::temp_dir().join(format!("openvpn_{}.ovpn", config_id));
std::fs::write(&config_path, &self.config.raw_config).map_err(VpnError::Io)?;
std::fs::write(&config_path, self.build_runtime_config()).map_err(VpnError::Io)?;
#[cfg(unix)]
{
@@ -84,43 +549,74 @@ impl OpenVpnSocks5Server {
let _ = std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600));
}
// Find a management port
let mgmt_listener = std::net::TcpListener::bind("127.0.0.1:0")
.map_err(|e| VpnError::Connection(format!("Failed to bind management port: {e}")))?;
let mgmt_port = mgmt_listener
.local_addr()
.map_err(|e| VpnError::Connection(format!("Failed to get management port: {e}")))?
.port();
drop(mgmt_listener);
let mgmt_port = if supports_management {
let mgmt_listener = std::net::TcpListener::bind("127.0.0.1:0")
.map_err(|e| VpnError::Connection(format!("Failed to bind management port: {e}")))?;
let port = mgmt_listener
.local_addr()
.map_err(|e| VpnError::Connection(format!("Failed to get management port: {e}")))?
.port();
drop(mgmt_listener);
Some(port)
} else {
log::info!(
"[vpn-worker] OpenVPN build does not support management; using log-based readiness"
);
None
};
let openvpn_log_path = std::env::temp_dir().join(format!("openvpn-{}.log", config_id));
let log_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&openvpn_log_path)
.map_err(VpnError::Io)?;
// Start OpenVPN with SOCKS proxy mode
let mut cmd = Command::new(&openvpn_bin);
cmd.arg("--config").arg(&config_path);
if let Some(mgmt_port) = mgmt_port {
cmd
.arg("--management")
.arg("127.0.0.1")
.arg(mgmt_port.to_string());
}
cmd
.arg("--config")
.arg(&config_path)
.arg("--management")
.arg("127.0.0.1")
.arg(mgmt_port.to_string())
.arg("--socks-proxy")
.arg("127.0.0.1")
.arg(self.port.to_string())
.arg("--verb")
.arg("3")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
.stdout(
log_file
.try_clone()
.map(Stdio::from)
.map_err(VpnError::Io)?,
)
.stderr(Stdio::from(log_file));
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.arg("--disable-dco");
if self.config.dev_type.starts_with("tun") {
cmd.arg("--windows-driver").arg("wintun");
}
cmd.creation_flags(CREATE_NO_WINDOW);
}
let mut child = cmd
.spawn()
.map_err(|e| VpnError::Connection(format!("Failed to start OpenVPN: {e}")))?;
// Wait for OpenVPN to start
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
match child.try_wait() {
Ok(Some(status)) => {
let _ = std::fs::remove_file(&config_path);
return Err(VpnError::Connection(format!(
"OpenVPN exited early with status: {status}. OpenVPN requires elevated privileges (sudo/admin)."
"OpenVPN exited immediately (status: {}). Last output:\n{}",
status,
Self::read_log_tail(&openvpn_log_path, 20)
)));
}
Ok(None) => {}
@@ -132,8 +628,15 @@ impl OpenVpnSocks5Server {
}
}
// Start a basic SOCKS5 proxy that tunnels through the OpenVPN TUN interface
let listener = TcpListener::bind(format!("127.0.0.1:{}", self.port))
let vpn_bind_ip = if let Some(mgmt_port) = mgmt_port {
Self::wait_for_openvpn_ready_via_management(&mut child, mgmt_port, &openvpn_log_path).await?
} else {
Self::wait_for_openvpn_ready_via_log(&mut child, &openvpn_log_path).await?
}
.unwrap_or(Ipv4Addr::UNSPECIFIED);
let vpn_bind_ip = Arc::new(vpn_bind_ip);
let listener = TcpListener::bind(("127.0.0.1", self.port))
.await
.map_err(|e| VpnError::Connection(format!("Failed to bind SOCKS5: {e}")))?;
@@ -142,10 +645,10 @@ impl OpenVpnSocks5Server {
.map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))?
.port();
if let Some(mut wc) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) {
wc.local_port = Some(actual_port);
wc.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
let _ = crate::vpn_worker_storage::save_vpn_worker_config(&wc);
if let Some(mut worker_config) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) {
worker_config.local_port = Some(actual_port);
worker_config.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
let _ = crate::vpn_worker_storage::save_vpn_worker_config(&worker_config);
}
log::info!(
@@ -156,10 +659,13 @@ impl OpenVpnSocks5Server {
loop {
match listener.accept().await {
Ok((client, _)) => {
tokio::spawn(Self::handle_socks5_client(client));
let bind_ip = vpn_bind_ip.clone();
tokio::spawn(async move {
let _ = Self::handle_socks5_client(client, bind_ip).await;
});
}
Err(e) => {
log::warn!("[vpn-worker] Accept error: {e}");
Err(error) => {
log::warn!("[vpn-worker] Accept error: {error}");
}
}
}
@@ -167,53 +673,119 @@ impl OpenVpnSocks5Server {
async fn handle_socks5_client(
mut client: TcpStream,
vpn_bind_ip: Arc<Ipv4Addr>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// SOCKS5 greeting
let mut buf = [0u8; 256];
let n = client.read(&mut buf).await?;
if n < 3 || buf[0] != 0x05 {
let mut greeting = [0u8; 2];
if let Err(error) = client.read_exact(&mut greeting).await {
if error.kind() != std::io::ErrorKind::UnexpectedEof {
log::debug!("[socks5] Failed to read greeting header: {}", error);
}
return Ok(());
}
if greeting[0] != 0x05 {
return Ok(());
}
let mut methods = vec![0u8; greeting[1] as usize];
if let Err(error) = client.read_exact(&mut methods).await {
if error.kind() != std::io::ErrorKind::UnexpectedEof {
log::debug!("[socks5] Failed to read methods list: {}", error);
}
return Ok(());
}
client.write_all(&[0x05, 0x00]).await?;
// SOCKS5 connect request
let n = client.read(&mut buf).await?;
if n < 10 || buf[0] != 0x05 || buf[1] != 0x01 {
let mut request_header = [0u8; 4];
if let Err(error) = client.read_exact(&mut request_header).await {
if error.kind() != std::io::ErrorKind::UnexpectedEof {
log::debug!("[socks5] Failed to read request header: {}", error);
}
return Ok(());
}
let dest_addr = match buf[3] {
if request_header[0] != 0x05 {
return Ok(());
}
if request_header[1] != 0x01 {
let _ = client
.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await;
return Ok(());
}
let target = match request_header[3] {
0x01 => {
let ip = std::net::Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]);
let port = u16::from_be_bytes([buf[8], buf[9]]);
format!("{}:{}", ip, port)
let mut addr_port = [0u8; 6];
client.read_exact(&mut addr_port).await?;
SocksTarget::Address(SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(
addr_port[0],
addr_port[1],
addr_port[2],
addr_port[3],
)),
u16::from_be_bytes([addr_port[4], addr_port[5]]),
))
}
0x03 => {
let domain_len = buf[4] as usize;
let domain = String::from_utf8_lossy(&buf[5..5 + domain_len]).to_string();
let port_start = 5 + domain_len;
let port = u16::from_be_bytes([buf[port_start], buf[port_start + 1]]);
format!("{}:{}", domain, port)
let mut len = [0u8; 1];
client.read_exact(&mut len).await?;
if len[0] == 0 {
return Ok(());
}
let mut domain = vec![0u8; len[0] as usize];
client.read_exact(&mut domain).await?;
let mut port = [0u8; 2];
client.read_exact(&mut port).await?;
SocksTarget::Domain(
String::from_utf8_lossy(&domain).to_string(),
u16::from_be_bytes(port),
)
}
0x04 => {
let mut addr_port = [0u8; 18];
client.read_exact(&mut addr_port).await?;
let mut octets = [0u8; 16];
octets.copy_from_slice(&addr_port[..16]);
SocksTarget::Address(SocketAddr::new(
IpAddr::V6(std::net::Ipv6Addr::from(octets)),
u16::from_be_bytes([addr_port[16], addr_port[17]]),
))
}
_ => {
let _ = client
.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await;
return Ok(());
}
_ => return Ok(()),
};
// Connect to destination through OpenVPN tunnel (OS routing handles it)
match TcpStream::connect(&dest_addr).await {
Ok(upstream) => {
match Self::connect_target(target, *vpn_bind_ip).await {
Ok((upstream, _address)) => {
client
.write_all(&[0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 0])
.await?;
let (mut cr, mut cw) = client.into_split();
let (mut ur, mut uw) = upstream.into_split();
let (mut client_read, mut client_write) = client.into_split();
let (mut upstream_read, mut upstream_write) = upstream.into_split();
let c2u = tokio::io::copy(&mut cr, &mut uw);
let u2c = tokio::io::copy(&mut ur, &mut cw);
let _ = tokio::try_join!(c2u, u2c);
let client_to_upstream = tokio::io::copy(&mut client_read, &mut upstream_write);
let upstream_to_client = tokio::io::copy(&mut upstream_read, &mut client_write);
let _ = tokio::try_join!(client_to_upstream, upstream_to_client)?;
}
Err(_) => {
Err(error) => {
log::debug!(
"[socks5] Failed to connect through OpenVPN tunnel: {}",
error
);
client
.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await?;
+40 -19
View File
@@ -370,6 +370,8 @@ impl WireGuardSocks5Server {
smol_handle: SocketHandle,
tcp_stream: TcpStream,
socks_done: bool,
connecting: bool,
greeting_done: bool,
read_buf: Vec<u8>,
dest_addr: Option<SocketAddr>,
}
@@ -391,6 +393,8 @@ impl WireGuardSocks5Server {
smol_handle: handle,
tcp_stream: stream,
socks_done: false,
connecting: false,
greeting_done: false,
read_buf: Vec::new(),
dest_addr: None,
});
@@ -409,7 +413,30 @@ impl WireGuardSocks5Server {
// Process each connection
let mut completed = Vec::new();
for (idx, conn) in connections.iter_mut().enumerate() {
if !conn.socks_done {
if conn.connecting {
let socket = sockets.get_mut::<TcpSocket>(conn.smol_handle);
if socket.may_send() {
let _ = conn.tcp_stream.try_write(&[
0x05,
0x00,
0x00,
0x01,
127,
0,
0,
1,
(actual_port >> 8) as u8,
(actual_port & 0xff) as u8,
]);
conn.connecting = false;
conn.socks_done = true;
} else if !socket.is_open() {
let _ = conn
.tcp_stream
.try_write(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
completed.push(idx);
}
} else if !conn.socks_done {
// Handle SOCKS5 handshake
let mut buf = [0u8; 512];
match conn.tcp_stream.try_read(&mut buf) {
@@ -427,19 +454,26 @@ impl WireGuardSocks5Server {
}
}
if conn.dest_addr.is_none() && conn.read_buf.len() >= 3 {
if !conn.greeting_done && conn.read_buf.len() >= 3 {
// SOCKS5 greeting: version, nmethods, methods
if conn.read_buf[0] != 0x05 {
completed.push(idx);
continue;
}
// Reply: no auth required
let _ = conn.tcp_stream.try_write(&[0x05, 0x00]);
let nmethods = conn.read_buf[1] as usize;
if conn.read_buf.len() < 2 + nmethods {
continue;
}
// Reply: no auth required
if conn.tcp_stream.try_write(&[0x05, 0x00]).is_err() {
completed.push(idx);
continue;
}
conn.read_buf.drain(..2 + nmethods);
conn.greeting_done = true;
}
if conn.dest_addr.is_none() && conn.read_buf.len() >= 10 {
if conn.greeting_done && conn.dest_addr.is_none() && conn.read_buf.len() >= 10 {
// SOCKS5 connect request
if conn.read_buf[0] != 0x05 || conn.read_buf[1] != 0x01 {
completed.push(idx);
@@ -539,20 +573,7 @@ impl WireGuardSocks5Server {
continue;
}
// Send SOCKS5 success reply
let _ = conn.tcp_stream.try_write(&[
0x05,
0x00,
0x00,
0x01,
127,
0,
0,
1,
(actual_port >> 8) as u8,
(actual_port & 0xff) as u8,
]);
conn.socks_done = true;
conn.connecting = true;
}
} else {
// Data relay between SOCKS5 client and smoltcp socket
+116 -46
View File
@@ -1,3 +1,4 @@
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,
@@ -5,12 +6,124 @@ use crate::vpn_worker_storage::{
};
use std::process::Stdio;
const VPN_WORKER_POLL_INTERVAL_MS: u64 = 100;
const VPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 10_000;
const OPENVPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 100_000;
async fn vpn_worker_accepting_connections(config: &VpnWorkerConfig) -> bool {
let Some(port) = config.local_port else {
return false;
};
if config
.local_url
.as_ref()
.is_none_or(|local_url| local_url.is_empty())
{
return false;
}
matches!(
tokio::time::timeout(
tokio::time::Duration::from_millis(VPN_WORKER_POLL_INTERVAL_MS),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await,
Ok(Ok(_))
)
}
fn worker_log_path(id: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("donut-vpn-{}.log", id))
}
fn read_worker_log(id: &str) -> String {
std::fs::read_to_string(worker_log_path(id)).unwrap_or_else(|_| "No log available".to_string())
}
async fn wait_for_vpn_worker_ready(
id: &str,
vpn_type: &str,
) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
let startup_timeout = if vpn_type == "openvpn" {
tokio::time::Duration::from_millis(OPENVPN_WORKER_STARTUP_TIMEOUT_MS)
} else {
tokio::time::Duration::from_millis(VPN_WORKER_STARTUP_TIMEOUT_MS)
};
let startup_deadline = tokio::time::Instant::now() + startup_timeout;
tokio::time::sleep(tokio::time::Duration::from_millis(
VPN_WORKER_POLL_INTERVAL_MS,
))
.await;
let mut attempts = 0u32;
loop {
tokio::time::sleep(tokio::time::Duration::from_millis(
VPN_WORKER_POLL_INTERVAL_MS,
))
.await;
if let Some(updated_config) = get_vpn_worker_config(id) {
let process_running = updated_config.pid.map(is_process_running).unwrap_or(false);
if !process_running && attempts > 2 {
let log_output = read_worker_log(id);
delete_vpn_worker_config(id);
return Err(format!("VPN worker process crashed. Log output:\n{}", log_output).into());
}
if vpn_worker_accepting_connections(&updated_config).await {
return Ok(updated_config);
}
}
attempts += 1;
if tokio::time::Instant::now() >= startup_deadline {
if let Some(config) = get_vpn_worker_config(id) {
let process_running = config.pid.map(is_process_running).unwrap_or(false);
let log_output = read_worker_log(id);
delete_vpn_worker_config(id);
return Err(
format!(
"VPN worker failed to start within {:.1}s. pid={:?}, process_running={}, local_url={:?}\n\nVPN worker log:\n{}",
startup_timeout.as_secs_f32(),
config.pid,
process_running,
config.local_url,
log_output
)
.into(),
);
}
delete_vpn_worker_config(id);
return Err("VPN worker config not found after spawn".into());
}
}
}
pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
for config in list_vpn_worker_configs() {
if let Some(pid) = config.pid {
if !is_process_running(pid) {
delete_vpn_worker_config(&config.id);
}
} else {
delete_vpn_worker_config(&config.id);
}
}
// Check if a VPN worker for this vpn_id already exists and is running
if let Some(existing) = find_vpn_worker_by_vpn_id(vpn_id) {
if let Some(pid) = existing.pid {
if is_process_running(pid) {
return Ok(existing);
if vpn_worker_accepting_connections(&existing).await {
return Ok(existing);
}
return wait_for_vpn_worker_ready(&existing.id, &existing.vpn_type).await;
}
}
// Worker config exists but process is dead, clean up
@@ -63,7 +176,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
save_vpn_worker_config(&config)?;
// Spawn detached VPN worker process
let exe = std::env::current_exe()?;
let exe = find_sidecar_executable("donut-proxy")?;
#[cfg(unix)]
{
@@ -149,50 +262,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
drop(child);
}
// Wait for the worker to update config with local_url
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let mut attempts = 0;
let max_attempts = 100; // 10 seconds max
loop {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
if let Some(updated_config) = get_vpn_worker_config(&id) {
if let Some(ref local_url) = updated_config.local_url {
if !local_url.is_empty() {
if let Some(port) = updated_config.local_port {
if let Ok(Ok(_)) = tokio::time::timeout(
tokio::time::Duration::from_millis(100),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await
{
return Ok(updated_config);
}
}
}
}
}
attempts += 1;
if attempts >= max_attempts {
if let Some(config) = get_vpn_worker_config(&id) {
let process_running = config.pid.map(is_process_running).unwrap_or(false);
// Clean up on failure
delete_vpn_worker_config(&id);
return Err(
format!(
"VPN worker failed to start in time. pid={:?}, process_running={}, local_url={:?}",
config.pid, process_running, config.local_url
)
.into(),
);
}
delete_vpn_worker_config(&id);
return Err("VPN worker config not found after spawn".into());
}
}
wait_for_vpn_worker_ready(&id, vpn_type_str).await
}
pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
+9 -7
View File
@@ -136,8 +136,10 @@ impl WayfernManager {
port: u16,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let url = format!("http://127.0.0.1:{port}/json/version");
let max_attempts = 50;
let delay = Duration::from_millis(100);
// On first launch, macOS Gatekeeper verifies the binary which can take 30+ seconds.
// Use a generous timeout (60s) to handle this.
let max_attempts = 120;
let delay = Duration::from_millis(500);
for attempt in 0..max_attempts {
match self.http_client.get(&url).send().await {
@@ -509,11 +511,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)",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.18.1",
"version": "0.20.2",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+172 -29
View File
@@ -16,12 +16,21 @@ const WIREGUARD_IMAGE: &str = "linuxserver/wireguard:latest";
const OPENVPN_IMAGE: &str = "kylemanna/openvpn:latest";
const WG_CONTAINER: &str = "donut-wg-test";
const OVPN_CONTAINER: &str = "donut-ovpn-test";
const OVPN_VOLUME: &str = "donut-ovpn-test-data";
/// Check if running in CI environment
pub fn is_ci() -> bool {
std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok()
}
fn has_external_wireguard_service() -> bool {
std::env::var("VPN_TEST_WG_HOST").is_ok()
}
fn has_external_openvpn_service() -> bool {
std::env::var("VPN_TEST_OVPN_HOST").is_ok()
}
/// Check if Docker is available
pub fn is_docker_available() -> bool {
Command::new("docker")
@@ -33,14 +42,10 @@ pub fn is_docker_available() -> bool {
/// Start a WireGuard test server and return client config
pub async fn start_wireguard_server() -> Result<WireGuardTestConfig, String> {
if is_ci() {
// In CI, use the service container configured in workflow
if has_external_wireguard_service() {
let host = std::env::var("VPN_TEST_WG_HOST").unwrap_or_else(|_| "localhost".into());
let port = std::env::var("VPN_TEST_WG_PORT").unwrap_or_else(|_| "51820".into());
// Wait for service to be ready
wait_for_service(&host, port.parse().unwrap_or(51820)).await?;
return get_ci_wireguard_config(&host, &port);
}
@@ -71,6 +76,8 @@ pub async fn start_wireguard_server() -> Result<WireGuardTestConfig, String> {
"SERVERPORT=51820",
"-e",
"PEERDNS=auto",
"-e",
"INTERNAL_SUBNET=10.64.0.0",
WIREGUARD_IMAGE,
])
.output()
@@ -105,14 +112,10 @@ pub async fn start_wireguard_server() -> Result<WireGuardTestConfig, String> {
/// Start an OpenVPN test server and return client config
pub async fn start_openvpn_server() -> Result<OpenVpnTestConfig, String> {
if is_ci() {
// In CI, use the service container configured in workflow
if has_external_openvpn_service() {
let host = std::env::var("VPN_TEST_OVPN_HOST").unwrap_or_else(|_| "localhost".into());
let port = std::env::var("VPN_TEST_OVPN_PORT").unwrap_or_else(|_| "1194".into());
// Wait for service to be ready
wait_for_service(&host, port.parse().unwrap_or(1194)).await?;
return get_ci_openvpn_config(&host, &port);
}
@@ -125,9 +128,139 @@ pub async fn start_openvpn_server() -> Result<OpenVpnTestConfig, String> {
.args(["rm", "-f", OVPN_CONTAINER])
.output();
// For OpenVPN, we need to initialize PKI first, which is complex
// For simplicity in tests, we'll use a pre-configured test config
Err("OpenVPN container setup requires pre-configured PKI. Use test fixtures instead.".to_string())
let _ = Command::new("docker")
.args(["volume", "rm", "-f", OVPN_VOLUME])
.output();
let create_volume = Command::new("docker")
.args(["volume", "create", OVPN_VOLUME])
.output()
.map_err(|e| format!("Failed to create OpenVPN test volume: {e}"))?;
if !create_volume.status.success() {
return Err(format!(
"Failed to create OpenVPN test volume: {}",
String::from_utf8_lossy(&create_volume.stderr)
));
}
let genconfig = Command::new("docker")
.args([
"run",
"--rm",
"-v",
&format!("{OVPN_VOLUME}:/etc/openvpn"),
"-e",
"EASYRSA_BATCH=1",
OPENVPN_IMAGE,
"ovpn_genconfig",
"-u",
"udp://127.0.0.1",
"-s",
"10.9.0.0/24",
])
.output()
.map_err(|e| format!("Failed to generate OpenVPN config: {e}"))?;
if !genconfig.status.success() {
return Err(format!(
"OpenVPN config generation failed: {}",
String::from_utf8_lossy(&genconfig.stderr)
));
}
let init_pki = Command::new("docker")
.args([
"run",
"--rm",
"-v",
&format!("{OVPN_VOLUME}:/etc/openvpn"),
"-e",
"EASYRSA_BATCH=1",
OPENVPN_IMAGE,
"ovpn_initpki",
"nopass",
])
.output()
.map_err(|e| format!("Failed to initialize OpenVPN PKI: {e}"))?;
if !init_pki.status.success() {
return Err(format!(
"OpenVPN PKI initialization failed: {}",
String::from_utf8_lossy(&init_pki.stderr)
));
}
let build_client = Command::new("docker")
.args([
"run",
"--rm",
"-v",
&format!("{OVPN_VOLUME}:/etc/openvpn"),
"-e",
"EASYRSA_BATCH=1",
OPENVPN_IMAGE,
"easyrsa",
"build-client-full",
"donut-test-client",
"nopass",
])
.output()
.map_err(|e| format!("Failed to build OpenVPN client certificate: {e}"))?;
if !build_client.status.success() {
return Err(format!(
"OpenVPN client certificate build failed: {}",
String::from_utf8_lossy(&build_client.stderr)
));
}
let start_server = Command::new("docker")
.args([
"run",
"-d",
"--name",
OVPN_CONTAINER,
"--cap-add=NET_ADMIN",
"-p",
"1194:1194/udp",
"-v",
&format!("{OVPN_VOLUME}:/etc/openvpn"),
OPENVPN_IMAGE,
])
.output()
.map_err(|e| format!("Failed to start OpenVPN container: {e}"))?;
if !start_server.status.success() {
return Err(format!(
"OpenVPN container start failed: {}",
String::from_utf8_lossy(&start_server.stderr)
));
}
sleep(Duration::from_secs(10)).await;
let client_config = Command::new("docker")
.args([
"run",
"--rm",
"-v",
&format!("{OVPN_VOLUME}:/etc/openvpn"),
OPENVPN_IMAGE,
"ovpn_getclient",
"donut-test-client",
])
.output()
.map_err(|e| format!("Failed to fetch OpenVPN client config: {e}"))?;
if !client_config.status.success() {
return Err(format!(
"Failed to read OpenVPN client config: {}",
String::from_utf8_lossy(&client_config.stderr)
));
}
let raw_config = String::from_utf8_lossy(&client_config.stdout).to_string();
Ok(OpenVpnTestConfig {
raw_config,
remote_host: "127.0.0.1".to_string(),
remote_port: 1194,
protocol: "udp".to_string(),
})
}
/// Stop all VPN test servers
@@ -135,21 +268,9 @@ pub async fn stop_vpn_servers() {
let _ = Command::new("docker")
.args(["rm", "-f", WG_CONTAINER, OVPN_CONTAINER])
.output();
}
/// Wait for a network service to be ready
async fn wait_for_service(host: &str, port: u16) -> Result<(), String> {
let timeout = Duration::from_secs(30);
let start = std::time::Instant::now();
while start.elapsed() < timeout {
if std::net::TcpStream::connect(format!("{host}:{port}")).is_ok() {
return Ok(());
}
sleep(Duration::from_millis(500)).await;
}
Err(format!("Timeout waiting for service at {host}:{port}"))
let _ = Command::new("docker")
.args(["volume", "rm", "-f", OVPN_VOLUME])
.output();
}
/// WireGuard test configuration
@@ -160,6 +281,7 @@ pub struct WireGuardTestConfig {
pub peer_public_key: String,
pub peer_endpoint: String,
pub allowed_ips: Vec<String>,
pub preshared_key: Option<String>,
}
/// OpenVPN test configuration
@@ -178,6 +300,7 @@ fn parse_wireguard_test_config(content: &str) -> Result<WireGuardTestConfig, Str
let mut peer_public_key = String::new();
let mut peer_endpoint = String::new();
let mut allowed_ips = vec!["0.0.0.0/0".to_string()];
let mut preshared_key = None;
let mut current_section = "";
for line in content.lines() {
@@ -205,6 +328,7 @@ fn parse_wireguard_test_config(content: &str) -> Result<WireGuardTestConfig, Str
("interface", "DNS") => dns = Some(value.to_string()),
("peer", "PublicKey") => peer_public_key = value.to_string(),
("peer", "Endpoint") => peer_endpoint = value.to_string(),
("peer", "PresharedKey") => preshared_key = Some(value.to_string()),
("peer", "AllowedIPs") => {
allowed_ips = value.split(',').map(|s| s.trim().to_string()).collect();
}
@@ -230,12 +354,21 @@ fn parse_wireguard_test_config(content: &str) -> Result<WireGuardTestConfig, Str
peer_public_key,
peer_endpoint,
allowed_ips,
preshared_key,
})
}
/// Get WireGuard config from CI environment
fn get_ci_wireguard_config(host: &str, port: &str) -> Result<WireGuardTestConfig, String> {
// In CI, use environment variables or test fixtures
if std::env::var("VPN_TEST_WG_PRIVATE_KEY").is_err()
|| std::env::var("VPN_TEST_WG_PUBLIC_KEY").is_err()
{
return Err(
"External WireGuard test service is configured, but VPN_TEST_WG_PRIVATE_KEY and VPN_TEST_WG_PUBLIC_KEY are missing"
.to_string(),
);
}
let private_key =
std::env::var("VPN_TEST_WG_PRIVATE_KEY").unwrap_or_else(|_| "test-private-key".to_string());
let public_key =
@@ -248,11 +381,21 @@ fn get_ci_wireguard_config(host: &str, port: &str) -> Result<WireGuardTestConfig
peer_public_key: public_key,
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(),
})
}
/// Get OpenVPN config from CI environment
fn get_ci_openvpn_config(host: &str, port: &str) -> Result<OpenVpnTestConfig, String> {
if let Ok(raw_config) = std::env::var("VPN_TEST_OVPN_RAW_CONFIG") {
return Ok(OpenVpnTestConfig {
raw_config,
remote_host: host.to_string(),
remote_port: port.parse().unwrap_or(1194),
protocol: "udp".to_string(),
});
}
let raw_config = format!(
r#"
client
+536 -3
View File
@@ -3,13 +3,22 @@
//! These tests verify VPN config parsing, storage, and tunnel functionality.
//! Connection tests require Docker and are skipped if Docker is not available.
mod common;
mod test_harness;
use common::TestUtils;
use donutbrowser_lib::vpn::{
detect_vpn_type, parse_openvpn_config, parse_wireguard_config, OpenVpnConfig, VpnConfig,
VpnStorage, VpnType, WireGuardConfig,
};
use serde_json::Value;
use serial_test::serial;
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::sleep;
// ============================================================================
// Config Parsing Tests
@@ -420,6 +429,530 @@ async fn test_tunnel_manager() {
assert_eq!(manager.active_count(), 0);
}
// NOTE: Actual connection tests require Docker containers running.
// These are meant to be run with the CI workflow that sets up service containers.
// To run locally: docker run -d --cap-add=NET_ADMIN -p 51820:51820/udp -e PEERS=1 linuxserver/wireguard
struct TestEnvGuard {
_root: PathBuf,
previous_data_dir: Option<String>,
previous_cache_dir: Option<String>,
}
impl TestEnvGuard {
fn new() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
static TEST_RUNTIME_ROOT: OnceLock<PathBuf> = OnceLock::new();
let root = TEST_RUNTIME_ROOT
.get_or_init(|| {
std::env::temp_dir().join(format!("donutbrowser-vpn-e2e-{}", std::process::id()))
})
.clone();
let data_dir = root.join("data");
let cache_dir = root.join("cache");
let vpn_dir = data_dir.join("vpn");
let _ = std::fs::remove_dir_all(&data_dir);
let _ = std::fs::remove_dir_all(&cache_dir);
std::fs::create_dir_all(&vpn_dir)?;
std::fs::create_dir_all(&data_dir)?;
std::fs::create_dir_all(&cache_dir)?;
let previous_data_dir = std::env::var("DONUTBROWSER_DATA_DIR").ok();
let previous_cache_dir = std::env::var("DONUTBROWSER_CACHE_DIR").ok();
std::env::set_var("DONUTBROWSER_DATA_DIR", &data_dir);
std::env::set_var("DONUTBROWSER_CACHE_DIR", &cache_dir);
Ok(Self {
_root: root,
previous_data_dir,
previous_cache_dir,
})
}
}
impl Drop for TestEnvGuard {
fn drop(&mut self) {
if let Some(value) = &self.previous_data_dir {
std::env::set_var("DONUTBROWSER_DATA_DIR", value);
} else {
std::env::remove_var("DONUTBROWSER_DATA_DIR");
}
if let Some(value) = &self.previous_cache_dir {
std::env::set_var("DONUTBROWSER_CACHE_DIR", value);
} else {
std::env::remove_var("DONUTBROWSER_CACHE_DIR");
}
}
}
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>> {
let cargo_manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?;
let project_root = PathBuf::from(cargo_manifest_dir)
.parent()
.unwrap()
.to_path_buf();
let proxy_binary_name = if cfg!(windows) {
"donut-proxy.exe"
} else {
"donut-proxy"
};
let proxy_binary = project_root
.join("src-tauri")
.join("target")
.join("debug")
.join(proxy_binary_name);
if !proxy_binary.exists() {
let build_status = tokio::process::Command::new("cargo")
.args(["build", "--bin", "donut-proxy"])
.current_dir(project_root.join("src-tauri"))
.status()
.await?;
if !build_status.success() {
return Err("Failed to build donut-proxy binary".into());
}
}
if !proxy_binary.exists() {
return Err("donut-proxy binary was not created successfully".into());
}
Ok(proxy_binary)
}
fn new_test_vpn_config(name: &str, vpn_type: VpnType, config_data: String) -> VpnConfig {
let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
VpnConfig {
id: uuid::Uuid::new_v4().to_string(),
name: name.to_string(),
vpn_type,
config_data,
created_at,
last_used: None,
sync_enabled: false,
last_sync: None,
}
}
fn build_wireguard_config(config: &test_harness::WireGuardTestConfig) -> String {
format!(
"[Interface]\nPrivateKey = {}\nAddress = {}\n{}\n[Peer]\nPublicKey = {}\n{}Endpoint = {}\nAllowedIPs = {}\nPersistentKeepalive = 25\n",
config.private_key,
config.address,
config
.dns
.as_ref()
.map(|dns| format!("DNS = {dns}\n"))
.unwrap_or_default(),
config.peer_public_key,
config
.preshared_key
.as_ref()
.map(|key| format!("PresharedKey = {key}\n"))
.unwrap_or_default(),
config.peer_endpoint,
config.allowed_ips.join(", ")
)
}
fn openvpn_client_available() -> bool {
if let Ok(path) = std::env::var("DONUTBROWSER_OPENVPN_BIN") {
return PathBuf::from(path).exists();
}
std::process::Command::new(if cfg!(windows) { "where" } else { "which" })
.arg("openvpn")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
#[cfg(windows)]
fn openvpn_adapter_available() -> bool {
let openvpn = std::process::Command::new("openvpn")
.arg("--show-adapters")
.output();
openvpn
.ok()
.map(|output| {
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
text
.lines()
.map(str::trim)
.any(|line| !line.is_empty() && !line.starts_with("Available adapters"))
})
.unwrap_or(false)
}
#[cfg(not(windows))]
fn openvpn_adapter_available() -> bool {
true
}
async fn start_proxy_with_upstream(
binary_path: &PathBuf,
upstream_proxy: &str,
bypass_rules: &[String],
blocklist_file: Option<&str>,
profile_id: Option<&str>,
) -> Result<ProxyProcess, Box<dyn std::error::Error + Send + Sync>> {
let upstream_url = url::Url::parse(upstream_proxy)?;
let host = upstream_url
.host_str()
.ok_or("Upstream proxy host is missing")?
.to_string();
let port = upstream_url
.port()
.ok_or("Upstream proxy port is missing")?;
let mut args = vec![
"proxy".to_string(),
"start".to_string(),
"--host".to_string(),
host,
"--proxy-port".to_string(),
port.to_string(),
"--type".to_string(),
upstream_url.scheme().to_string(),
];
if !bypass_rules.is_empty() {
args.push("--bypass-rules".to_string());
args.push(serde_json::to_string(bypass_rules)?);
}
if let Some(blocklist_file) = blocklist_file {
args.push("--blocklist-file".to_string());
args.push(blocklist_file.to_string());
}
if let Some(profile_id) = profile_id {
args.push("--profile-id".to_string());
args.push(profile_id.to_string());
}
let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
let output = TestUtils::execute_command(binary_path, &arg_refs).await?;
if !output.status.success() {
return Err(
format!(
"Failed to start local proxy - stdout: {}, stderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
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(),
})
}
async fn stop_proxy(
binary_path: &PathBuf,
proxy_id: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let output =
TestUtils::execute_command(binary_path, &["proxy", "stop", "--id", proxy_id]).await?;
if !output.status.success() {
return Err(
format!(
"Failed to stop proxy '{}' - stdout: {}, stderr: {}",
proxy_id,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
Ok(())
}
async fn raw_http_request_via_proxy(
local_port: u16,
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 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?;
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;
test_harness::stop_vpn_servers().await;
}
async fn wait_for_file(
path: &std::path::Path,
timeout: Duration,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let deadline = tokio::time::Instant::now() + timeout;
while tokio::time::Instant::now() < deadline {
if path.exists() {
return Ok(());
}
sleep(Duration::from_millis(250)).await;
}
Err(format!("Timed out waiting for file: {}", path.display()).into())
}
async fn run_proxy_feature_suite(
binary_path: &PathBuf,
vpn_id: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let vpn_worker = donutbrowser_lib::vpn_worker_runner::start_vpn_worker(vpn_id)
.await
.map_err(|error| error.to_string())?;
let vpn_upstream = vpn_worker
.local_url
.clone()
.ok_or("VPN worker did not expose a local URL")?;
let profile_id = format!("vpn-e2e-{}", uuid::Uuid::new_v4());
let proxy =
start_proxy_with_upstream(binary_path, &vpn_upstream, &[], None, Some(&profile_id)).await?;
sleep(Duration::from_millis(500)).await;
let http_response =
raw_http_request_via_proxy(proxy.local_port, "http://example.com/", "example.com").await?;
assert!(
http_response.contains("Example Domain"),
"HTTP traffic through donut-proxy+VPN 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));
wait_for_file(&stats_file, Duration::from_secs(8)).await?;
assert!(
stats_file.exists(),
"Traffic stats should exist for VPN-backed local proxy"
);
let stats: Value = serde_json::from_str(&std::fs::read_to_string(&stats_file)?)?;
let total_requests = stats["total_requests"].as_u64().unwrap_or_default();
assert!(
total_requests > 0,
"Traffic stats should record requests for VPN-backed local proxy"
);
let domains = stats["domains"]
.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"
);
stop_proxy(binary_path, &proxy.id).await?;
let blocklist_file = tempfile::NamedTempFile::new()?;
std::fs::write(blocklist_file.path(), "example.com\n")?;
let blocked_proxy = start_proxy_with_upstream(
binary_path,
&vpn_upstream,
&[],
blocklist_file.path().to_str(),
None,
)
.await?;
let blocked_response = raw_http_request_via_proxy(
blocked_proxy.local_port,
"http://example.com/",
"example.com",
)
.await?;
assert!(
blocked_response.contains("403") || blocked_response.contains("Blocked by DNS blocklist"),
"DNS blocklist should be enforced before forwarding to the VPN upstream"
);
stop_proxy(binary_path, &blocked_proxy.id).await?;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let bypass_target_port = listener.local_addr()?.port();
let bypass_server = tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let io = hyper_util::rt::TokioIo::new(stream);
tokio::spawn(async move {
let service = hyper::service::service_fn(|_req| async move {
Ok::<_, hyper::Error>(
hyper::Response::builder()
.status(hyper::StatusCode::OK)
.body(http_body_util::Full::new(hyper::body::Bytes::from(
"VPN-BYPASS-OK",
)))
.unwrap(),
)
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(io, service)
.await;
});
}
});
let bypass_proxy = start_proxy_with_upstream(
binary_path,
&vpn_upstream,
&["127.0.0.1".to_string(), "localhost".to_string()],
None,
None,
)
.await?;
let bypass_response = raw_http_request_via_proxy(
bypass_proxy.local_port,
&format!("http://127.0.0.1:{bypass_target_port}/"),
&format!("127.0.0.1:{bypass_target_port}"),
)
.await?;
assert!(
bypass_response.contains("VPN-BYPASS-OK"),
"Bypass rules should still work when donut-proxy is chained to a VPN worker"
);
stop_proxy(binary_path, &bypass_proxy.id).await?;
bypass_server.abort();
donutbrowser_lib::vpn_worker_runner::stop_vpn_worker(&vpn_worker.id)
.await
.map_err(|error| error.to_string())?;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_wireguard_traffic_flows_through_donut_proxy(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let _env = TestEnvGuard::new()?;
cleanup_runtime().await;
if !test_harness::is_docker_available() {
eprintln!("skipping WireGuard e2e test because Docker is unavailable");
return Ok(());
}
let binary_path = ensure_donut_proxy_binary().await?;
let wg_config = match test_harness::start_wireguard_server().await {
Ok(config) => config,
Err(error) => {
eprintln!("skipping WireGuard e2e test: {error}");
return Ok(());
}
};
let vpn_config = new_test_vpn_config(
"WireGuard E2E",
VpnType::WireGuard,
build_wireguard_config(&wg_config),
);
{
let storage = donutbrowser_lib::vpn::VPN_STORAGE.lock().unwrap();
storage.save_config(&vpn_config)?;
}
let result = run_proxy_feature_suite(&binary_path, &vpn_config.id).await;
cleanup_runtime().await;
result
}
#[tokio::test]
#[serial]
async fn test_openvpn_traffic_flows_through_donut_proxy(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let _env = TestEnvGuard::new()?;
cleanup_runtime().await;
if std::env::var("DONUTBROWSER_RUN_OPENVPN_E2E")
.ok()
.as_deref()
!= Some("1")
{
eprintln!("skipping OpenVPN e2e test because DONUTBROWSER_RUN_OPENVPN_E2E is not set");
return Ok(());
}
if !test_harness::is_docker_available() {
eprintln!("skipping OpenVPN e2e test because Docker is unavailable");
return Ok(());
}
if !openvpn_client_available() {
eprintln!("skipping OpenVPN e2e test because the OpenVPN client binary is unavailable");
return Ok(());
}
if !openvpn_adapter_available() {
eprintln!("skipping OpenVPN e2e test because no Windows OpenVPN adapter is available");
return Ok(());
}
let binary_path = ensure_donut_proxy_binary().await?;
let ovpn_config = match test_harness::start_openvpn_server().await {
Ok(config) => config,
Err(error) => {
eprintln!("skipping OpenVPN e2e test: {error}");
return Ok(());
}
};
let vpn_config = new_test_vpn_config("OpenVPN E2E", VpnType::OpenVPN, ovpn_config.raw_config);
{
let storage = donutbrowser_lib::vpn::VPN_STORAGE.lock().unwrap();
storage.save_config(&vpn_config)?;
}
let result = run_proxy_feature_suite(&binary_path, &vpn_config.id).await;
cleanup_runtime().await;
result
}
+2 -19
View File
@@ -1,14 +1,7 @@
"use client";
import { Geist, Geist_Mono } from "next/font/google";
import "@/styles/globals.css";
import "flag-icons/css/flag-icons.min.css";
import { useEffect } from "react";
import { I18nProvider } from "@/components/i18n-provider";
import { CustomThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { WindowDragArea } from "@/components/window-drag-area";
import { setupLogging } from "@/lib/logger";
import { ClientProviders } from "@/components/client-providers";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -25,22 +18,12 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
useEffect(() => {
void setupLogging();
}, []);
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-hidden bg-background`}
>
<I18nProvider>
<CustomThemeProvider>
<WindowDragArea />
<TooltipProvider>{children}</TooltipProvider>
<Toaster />
</CustomThemeProvider>
</I18nProvider>
<ClientProviders>{children}</ClientProviders>
</body>
</html>
);
+43 -21
View File
@@ -280,7 +280,7 @@ export default function Home() {
const [processingUrls, setProcessingUrls] = useState<Set<string>>(new Set());
const handleUrlOpen = useCallback(
async (url: string) => {
(url: string) => {
// Prevent duplicate processing of the same URL
if (processingUrls.has(url)) {
console.log("URL already being processed:", url);
@@ -324,7 +324,7 @@ export default function Home() {
const currentUrl = await getCurrent();
if (currentUrl && currentUrl.length > 0) {
console.log("Startup URL detected:", currentUrl[0]);
void handleUrlOpen(currentUrl[0]);
handleUrlOpen(currentUrl[0]);
}
} catch (error) {
console.error("Failed to check current URL:", error);
@@ -372,7 +372,7 @@ export default function Home() {
}
}, [proxiesError]);
const checkAllPermissions = useCallback(async () => {
const checkAllPermissions = useCallback(() => {
try {
// Wait for permissions to be initialized before checking
if (!isInitialized) {
@@ -413,13 +413,13 @@ export default function Home() {
// Listen for URL open events from the deep link handler (when app is already running)
await listen<string>("url-open-request", (event) => {
console.log("Received URL open request:", event.payload);
void handleUrlOpen(event.payload);
handleUrlOpen(event.payload);
});
// Listen for show profile selector events
await listen<string>("show-profile-selector", (event) => {
console.log("Received show profile selector request:", event.payload);
void handleUrlOpen(event.payload);
handleUrlOpen(event.payload);
});
// Listen for show create profile dialog events
@@ -437,7 +437,7 @@ export default function Home() {
// Listen for custom logo click events
const handleLogoUrlEvent = (event: CustomEvent) => {
console.log("Received logo URL event:", event.detail);
void handleUrlOpen(event.detail);
handleUrlOpen(event.detail);
};
window.addEventListener(
@@ -515,6 +515,8 @@ export default function Home() {
groupId?: string;
extensionGroupId?: string;
ephemeral?: boolean;
dnsBlocklist?: string;
launchHook?: string;
}) => {
try {
const profile = await invoke<BrowserProfile>(
@@ -529,9 +531,11 @@ export default function Home() {
camoufoxConfig: profileData.camoufoxConfig,
wayfernConfig: profileData.wayfernConfig,
groupId:
profileData.groupId ||
profileData.groupId ??
(selectedGroupId !== "default" ? selectedGroupId : undefined),
ephemeral: profileData.ephemeral,
dnsBlocklist: profileData.dnsBlocklist,
launchHook: profileData.launchHook,
},
);
@@ -764,13 +768,13 @@ export default function Home() {
setCookieManagementDialogOpen(true);
}, []);
const handleGroupAssignmentComplete = useCallback(async () => {
const handleGroupAssignmentComplete = useCallback(() => {
// No need to manually reload - useProfileEvents will handle the update
setGroupAssignmentDialogOpen(false);
setSelectedProfilesForGroup([]);
}, []);
const handleProxyAssignmentComplete = useCallback(async () => {
const handleProxyAssignmentComplete = useCallback(() => {
// No need to manually reload - useProfileEvents will handle the update
setProxyAssignmentDialogOpen(false);
setSelectedProfilesForProxy([]);
@@ -810,7 +814,7 @@ export default function Home() {
let unlistenStatus: (() => void) | undefined;
let unlistenProgress: (() => void) | undefined;
const profilesWithTransfer = new Set<string>();
(async () => {
void (async () => {
try {
unlistenStatus = await listen<{
profile_id: string;
@@ -898,7 +902,7 @@ export default function Home() {
};
let cleanup: (() => void) | undefined;
setupListeners().then((cleanupFn) => {
void setupListeners().then((cleanupFn) => {
cleanup = cleanupFn;
});
@@ -995,7 +999,7 @@ export default function Home() {
// Check permissions when they are initialized
useEffect(() => {
if (isInitialized) {
void checkAllPermissions();
checkAllPermissions();
}
}, [isInitialized, checkAllPermissions]);
@@ -1093,7 +1097,9 @@ export default function Home() {
crossOsUnlocked={crossOsUnlocked}
syncUnlocked={syncUnlocked}
getProfileSyncInfo={getProfileSyncInfo}
onLaunchWithSync={(profile) => setSyncLeaderProfile(profile)}
onLaunchWithSync={(profile) => {
setSyncLeaderProfile(profile);
}}
/>
</div>
</main>
@@ -1167,7 +1173,9 @@ export default function Home() {
<CloneProfileDialog
isOpen={!!cloneProfile}
onClose={() => setCloneProfile(null)}
onClose={() => {
setCloneProfile(null);
}}
profile={cloneProfile}
/>
@@ -1197,7 +1205,9 @@ export default function Home() {
<ExtensionManagementDialog
isOpen={extensionManagementDialogOpen}
onClose={() => setExtensionManagementDialogOpen(false)}
onClose={() => {
setExtensionManagementDialogOpen(false);
}}
limitedMode={!crossOsUnlocked}
/>
@@ -1242,7 +1252,9 @@ export default function Home() {
selectedProfiles={selectedProfilesForCookies}
profiles={profiles}
runningProfiles={runningProfiles}
onCopyComplete={() => setSelectedProfilesForCookies([])}
onCopyComplete={() => {
setSelectedProfilesForCookies([]);
}}
/>
<CookieManagementDialog
@@ -1256,7 +1268,9 @@ export default function Home() {
<DeleteConfirmationDialog
isOpen={showBulkDeleteConfirmation}
onClose={() => setShowBulkDeleteConfirmation(false)}
onClose={() => {
setShowBulkDeleteConfirmation(false);
}}
onConfirm={confirmBulkDelete}
title="Delete Selected Profiles"
description={`This action cannot be undone. This will permanently delete ${selectedProfiles.length} profile${selectedProfiles.length !== 1 ? "s" : ""} and all associated data.`}
@@ -1279,7 +1293,9 @@ export default function Home() {
<SyncAllDialog
isOpen={syncAllDialogOpen}
onClose={() => setSyncAllDialogOpen(false)}
onClose={() => {
setSyncAllDialogOpen(false);
}}
/>
<ProfileSyncDialog
@@ -1289,7 +1305,9 @@ export default function Home() {
setCurrentProfileForSync(null);
}}
profile={currentProfileForSync}
onSyncConfigOpen={() => setSyncConfigDialogOpen(true)}
onSyncConfigOpen={() => {
setSyncConfigDialogOpen(true);
}}
/>
{/* Wayfern Terms and Conditions Dialog - shown if terms not accepted */}
@@ -1313,7 +1331,9 @@ export default function Home() {
{/* Launch on Login Dialog - shown on every startup until enabled or declined */}
<LaunchOnLoginDialog
isOpen={launchOnLoginDialogOpen}
onClose={() => setLaunchOnLoginDialogOpen(false)}
onClose={() => {
setLaunchOnLoginDialogOpen(false);
}}
/>
<WindowResizeWarningDialog
@@ -1328,7 +1348,9 @@ export default function Home() {
<SyncFollowerDialog
isOpen={syncLeaderProfile !== null}
onClose={() => setSyncLeaderProfile(null)}
onClose={() => {
setSyncLeaderProfile(null);
}}
leaderProfile={syncLeaderProfile}
allProfiles={profiles}
runningProfiles={runningProfiles}
+4 -1
View File
@@ -44,7 +44,9 @@ export function AppUpdateToast({
<span className="text-sm font-semibold text-foreground">
{updateReady
? "Update ready, restart to apply"
: "Manual download required"}
: updateInfo.repo_update
? "Update available via package manager"
: "Manual download required"}
</span>
<div className="text-xs text-muted-foreground">
{updateInfo.current_version} {updateInfo.new_version}
@@ -72,6 +74,7 @@ export function AppUpdateToast({
Restart Now
</RippleButton>
) : (
!updateInfo.repo_update &&
updateInfo.manual_update_required && (
<RippleButton
onClick={handleViewRelease}
+6 -7
View File
@@ -46,12 +46,6 @@ export function BandwidthMiniChart({
return result;
}, [data]);
// Find max value for scaling
const _maxBandwidth = React.useMemo(() => {
const max = Math.max(...chartData.map((d) => d.bandwidth), 1);
return max;
}, [chartData]);
// Use external bandwidth if provided, otherwise calculate from last data point
const currentBandwidth =
externalBandwidth ?? chartData[chartData.length - 1]?.bandwidth ?? 0;
@@ -74,7 +68,12 @@ export function BandwidthMiniChart({
)}
>
<div className="flex-1 h-3 pointer-events-none">
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={1}
minHeight={1}
>
<AreaChart
data={chartData}
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useEffect } from "react";
import { I18nProvider } from "@/components/i18n-provider";
import { CustomThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { WindowDragArea } from "@/components/window-drag-area";
import { setupLogging } from "@/lib/logger";
export function ClientProviders({ children }: { children: React.ReactNode }) {
useEffect(() => {
void setupLogging();
}, []);
return (
<I18nProvider>
<CustomThemeProvider>
<WindowDragArea />
<TooltipProvider>{children}</TooltipProvider>
<Toaster />
</CustomThemeProvider>
</I18nProvider>
);
}
+9 -2
View File
@@ -69,7 +69,12 @@ export function CloneProfileDialog({
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("profileInfo.clone.title")}</DialogTitle>
@@ -80,7 +85,9 @@ export function CloneProfileDialog({
<Input
ref={inputRef}
value={name}
onChange={(e) => setName(e.target.value)}
onChange={(e) => {
setName(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") void handleClone();
}}
+9 -3
View File
@@ -44,9 +44,15 @@ export function CommercialTrialModal({
<Dialog open={isOpen}>
<DialogContent
className="sm:max-w-md"
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => {
e.preventDefault();
}}
onPointerDownOutside={(e) => {
e.preventDefault();
}}
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>Commercial Trial Expired</DialogTitle>
+44 -29
View File
@@ -50,12 +50,13 @@ interface CookieCopyDialogProps {
onCopyComplete?: () => void;
}
type SelectionState = {
[domain: string]: {
type SelectionState = Record<
string,
{
allSelected: boolean;
cookies: Set<string>;
};
};
}
>;
export function CookieCopyDialog({
isOpen,
@@ -76,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(
@@ -109,7 +115,7 @@ export function CookieCopyDialog({
const domainSelection = selection[domain];
if (domainSelection.allSelected) {
const domainData = cookieData?.domains.find((d) => d.domain === domain);
count += domainData?.cookie_count || 0;
count += domainData?.cookie_count ?? 0;
} else {
count += domainSelection.cookies.size;
}
@@ -147,22 +153,21 @@ export function CookieCopyDialog({
const toggleDomain = useCallback(
(domain: string, cookies: UnifiedCookie[]) => {
setSelection((prev) => {
const current = prev[domain];
const allSelected = current?.allSelected || false;
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)),
},
};
});
},
[],
@@ -171,7 +176,7 @@ export function CookieCopyDialog({
const toggleCookie = useCallback(
(domain: string, cookieName: string, totalCookies: number) => {
setSelection((prev) => {
const current = prev[domain] || {
const current = prev[domain] ?? {
allSelected: false,
cookies: new Set<string>(),
};
@@ -412,7 +417,9 @@ export function CookieCopyDialog({
<Input
placeholder="Search domains or cookies..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onChange={(e) => {
setSearchQuery(e.target.value);
}}
className="pl-8"
/>
</div>
@@ -500,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 || false;
const selectedCount = domainSelection?.cookies.size || 0;
const isAllSelected = domainSelection?.allSelected ?? false;
const selectedCount = domainSelection?.cookies.size ?? 0;
const isPartial =
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
@@ -511,13 +522,17 @@ function DomainRow({
<div className="flex items-center gap-2 p-2 hover:bg-accent/50 rounded">
<Checkbox
checked={isAllSelected || isPartial}
onCheckedChange={() => onToggleDomain(domain.domain, domain.cookies)}
onCheckedChange={() => {
onToggleDomain(domain.domain, domain.cookies);
}}
className={isPartial ? "opacity-70" : ""}
/>
<button
type="button"
className="flex items-center gap-1 flex-1 text-left bg-transparent border-none cursor-pointer"
onClick={() => onToggleExpand(domain.domain)}
onClick={() => {
onToggleExpand(domain.domain);
}}
>
{isExpanded ? (
<LuChevronDown className="w-4 h-4" />
@@ -534,7 +549,7 @@ function DomainRow({
<div className="ml-8 pl-2 border-l space-y-1">
{domain.cookies.map((cookie) => {
const isSelected =
domainSelection?.cookies.has(cookie.name) || false;
domainSelection?.cookies.has(cookie.name) ?? false;
return (
<div
key={`${domain.domain}-${cookie.name}`}
@@ -542,13 +557,13 @@ function DomainRow({
>
<Checkbox
checked={isSelected || isAllSelected}
onCheckedChange={() =>
onCheckedChange={() => {
onToggleCookie(
domain.domain,
cookie.name,
domain.cookie_count,
)
}
);
}}
/>
<span className="truncate">{cookie.name}</span>
</div>
+27 -17
View File
@@ -45,12 +45,13 @@ interface CookieManagementDialogProps {
initialTab?: "import" | "export";
}
type SelectionState = {
[domain: string]: {
type SelectionState = Record<
string,
{
allSelected: boolean;
cookies: Set<string>;
};
};
}
>;
const countCookies = (content: string): number => {
const trimmed = content.trim();
@@ -150,7 +151,7 @@ export function CookieManagementDialog({
const domainData = exportCookieData?.domains.find(
(d) => d.domain === domain,
);
count += domainData?.cookie_count || 0;
count += domainData?.cookie_count ?? 0;
} else {
count += ds.cookies.size;
}
@@ -308,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;
@@ -329,7 +333,7 @@ export function CookieManagementDialog({
const toggleCookie = useCallback(
(domain: string, cookieName: string, totalCookies: number) => {
setExportSelection((prev) => {
const current = prev[domain] || {
const current = prev[domain] ?? {
allSelected: false,
cookies: new Set<string>(),
};
@@ -485,7 +489,9 @@ export function CookieManagementDialog({
<Label>Format</Label>
<Select
value={format}
onValueChange={(v) => setFormat(v as "netscape" | "json")}
onValueChange={(v) => {
setFormat(v as "netscape" | "json");
}}
>
<SelectTrigger>
<SelectValue />
@@ -589,8 +595,8 @@ function ExportDomainRow({
onToggleExpand,
}: ExportDomainRowProps) {
const domainSelection = selection[domain.domain];
const isAllSelected = domainSelection?.allSelected || false;
const selectedCount = domainSelection?.cookies.size || 0;
const isAllSelected = domainSelection?.allSelected ?? false;
const selectedCount = domainSelection?.cookies.size ?? 0;
const isPartial =
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
@@ -599,13 +605,17 @@ function ExportDomainRow({
<div className="flex items-center gap-2 p-1.5 hover:bg-accent/50 rounded">
<Checkbox
checked={isAllSelected || isPartial}
onCheckedChange={() => onToggleDomain(domain.domain, domain.cookies)}
onCheckedChange={() => {
onToggleDomain(domain.domain, domain.cookies);
}}
className={isPartial ? "opacity-70" : ""}
/>
<button
type="button"
className="flex items-center gap-1 flex-1 text-left text-sm bg-transparent border-none cursor-pointer"
onClick={() => onToggleExpand(domain.domain)}
onClick={() => {
onToggleExpand(domain.domain);
}}
>
{isExpanded ? (
<LuChevronDown className="w-3.5 h-3.5" />
@@ -622,7 +632,7 @@ function ExportDomainRow({
<div className="ml-7 pl-2 border-l space-y-0.5">
{domain.cookies.map((cookie) => {
const isSelected =
domainSelection?.cookies.has(cookie.name) || false;
domainSelection?.cookies.has(cookie.name) ?? false;
return (
<div
key={`${domain.domain}-${cookie.name}`}
@@ -630,13 +640,13 @@ function ExportDomainRow({
>
<Checkbox
checked={isSelected || isAllSelected}
onCheckedChange={() =>
onCheckedChange={() => {
onToggleCookie(
domain.domain,
cookie.name,
domain.cookie_count,
)
}
);
}}
/>
<span className="truncate">{cookie.name}</span>
</div>
+3 -1
View File
@@ -80,7 +80,9 @@ export function CreateGroupDialog({
id="group-name"
placeholder="Enter group name..."
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
onChange={(e) => {
setGroupName(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && groupName.trim()) {
void handleCreate();
+133 -29
View File
@@ -84,6 +84,8 @@ interface CreateProfileDialogProps {
groupId?: string;
extensionGroupId?: string;
ephemeral?: boolean;
dnsBlocklist?: string;
launchHook?: string;
}) => Promise<void>;
selectedGroupId?: string;
crossOsUnlocked?: boolean;
@@ -124,6 +126,8 @@ export function CreateProfileDialog({
useState<BrowserTypeString | null>(null);
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>(() => ({
@@ -148,6 +152,7 @@ export function CreateProfileDialog({
setSelectedBrowser(null);
setProfileName("");
setSelectedProxyId(undefined);
setLaunchHook("");
};
const handleTabChange = (value: string) => {
@@ -156,6 +161,7 @@ export function CreateProfileDialog({
setSelectedBrowser(null);
setProfileName("");
setSelectedProxyId(undefined);
setLaunchHook("");
};
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
@@ -172,11 +178,13 @@ export function CreateProfileDialog({
useEffect(() => {
if (isOpen) {
invoke<{ id: string; name: string; extension_ids: string[] }[]>(
void invoke<{ id: string; name: string; extension_ids: string[] }[]>(
"list_extension_groups",
)
.then(setExtensionGroups)
.catch(() => setExtensionGroups([]));
.catch(() => {
setExtensionGroups([]);
});
}
}, [isOpen]);
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>();
@@ -393,6 +401,8 @@ export function CreateProfileDialog({
selectedGroupId !== "default" ? selectedGroupId : undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
});
} else {
// Default to Camoufox
@@ -418,6 +428,8 @@ export function CreateProfileDialog({
selectedGroupId !== "default" ? selectedGroupId : undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
});
}
} else {
@@ -441,6 +453,8 @@ export function CreateProfileDialog({
releaseType: bestVersion.releaseType,
proxyId: selectedProxyId,
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
});
}
@@ -462,6 +476,7 @@ export function CreateProfileDialog({
setActiveTab("anti-detect");
setSelectedBrowser(null);
setSelectedProxyId(undefined);
setLaunchHook("");
setReleaseTypes({});
setIsLoadingReleaseTypes(false);
setReleaseTypesError(null);
@@ -553,7 +568,9 @@ export function CreateProfileDialog({
<div className="space-y-3 pt-8">
{/* Wayfern (Chromium) - First */}
<Button
onClick={() => handleBrowserSelect("wayfern")}
onClick={() => {
handleBrowserSelect("wayfern");
}}
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
@@ -577,7 +594,9 @@ export function CreateProfileDialog({
{/* Camoufox (Firefox) - Second */}
<Button
onClick={() => handleBrowserSelect("camoufox")}
onClick={() => {
handleBrowserSelect("camoufox");
}}
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
@@ -620,9 +639,9 @@ export function CreateProfileDialog({
return (
<Button
key={browser.value}
onClick={() =>
handleBrowserSelect(browser.value)
}
onClick={() => {
handleBrowserSelect(browser.value);
}}
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
@@ -657,14 +676,16 @@ export function CreateProfileDialog({
<Input
id="profile-name"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
onChange={(e) => {
setProfileName(e.target.value);
}}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!isCreateDisabled &&
!isCreating
) {
handleCreate();
void handleCreate();
}
}}
placeholder="Enter profile name"
@@ -677,9 +698,9 @@ export function CreateProfileDialog({
<Checkbox
id="ephemeral"
checked={ephemeral}
onCheckedChange={(checked) =>
setEphemeral(checked === true)
}
onCheckedChange={(checked) => {
setEphemeral(checked === true);
}}
/>
<Label htmlFor="ephemeral" className="font-medium">
{t("profiles.ephemeral")}
@@ -746,7 +767,9 @@ export function CreateProfileDialog({
})()}
</p>
<LoadingButton
onClick={() => handleDownload("wayfern")}
onClick={() => {
void handleDownload("wayfern");
}}
isLoading={isBrowserCurrentlyDownloading(
"wayfern",
)}
@@ -848,7 +871,9 @@ export function CreateProfileDialog({
})()}
</p>
<LoadingButton
onClick={() => handleDownload("camoufox")}
onClick={() => {
void handleDownload("camoufox");
}}
isLoading={isBrowserCurrentlyDownloading(
"camoufox",
)}
@@ -955,9 +980,9 @@ export function CreateProfileDialog({
})()}
</p>
<LoadingButton
onClick={() =>
handleDownload(selectedBrowser)
}
onClick={() => {
void handleDownload(selectedBrowser);
}}
isLoading={isBrowserCurrentlyDownloading(
selectedBrowser,
)}
@@ -1014,7 +1039,9 @@ export function CreateProfileDialog({
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowProxyForm(true)}
onClick={() => {
setShowProxyForm(true);
}}
className="px-2 h-7 text-xs"
>
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
@@ -1148,17 +1175,71 @@ 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>
<Select
value={dnsBlocklist || "none"}
onValueChange={(val) => {
setDnsBlocklist(val === "none" ? "" : val);
}}
>
<SelectTrigger>
<SelectValue
placeholder={t("dnsBlocklist.none")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("dnsBlocklist.none")}
</SelectItem>
<SelectItem value="light">
{t("dnsBlocklist.light")}
</SelectItem>
<SelectItem value="normal">
{t("dnsBlocklist.normal")}
</SelectItem>
<SelectItem value="pro">
{t("dnsBlocklist.pro")}
</SelectItem>
<SelectItem value="pro_plus">
{t("dnsBlocklist.proPlus")}
</SelectItem>
<SelectItem value="ultimate">
{t("dnsBlocklist.ultimate")}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Extension Group */}
{extensionGroups.length > 0 && (
<div className="space-y-2">
<Label>{t("extensions.extensionGroup")}</Label>
<Select
value={selectedExtensionGroupId || "none"}
onValueChange={(val) =>
value={selectedExtensionGroupId ?? "none"}
onValueChange={(val) => {
setSelectedExtensionGroupId(
val === "none" ? undefined : val,
)
}
);
}}
>
<SelectTrigger>
<SelectValue
@@ -1190,14 +1271,16 @@ export function CreateProfileDialog({
<Input
id="profile-name"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
onChange={(e) => {
setProfileName(e.target.value);
}}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!isCreateDisabled &&
!isCreating
) {
handleCreate();
void handleCreate();
}
}}
placeholder="Enter profile name"
@@ -1251,9 +1334,9 @@ export function CreateProfileDialog({
})()}
</p>
<LoadingButton
onClick={() =>
handleDownload(selectedBrowser)
}
onClick={() => {
void handleDownload(selectedBrowser);
}}
isLoading={isBrowserCurrentlyDownloading(
selectedBrowser,
)}
@@ -1305,7 +1388,9 @@ export function CreateProfileDialog({
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowProxyForm(true)}
onClick={() => {
setShowProxyForm(true);
}}
className="px-2 h-7 text-xs"
>
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
@@ -1438,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>
</>
@@ -1470,7 +1572,9 @@ export function CreateProfileDialog({
</DialogContent>
<ProxyFormDialog
isOpen={showProxyForm}
onClose={() => setShowProxyForm(false)}
onClose={() => {
setShowProxyForm(false);
}}
/>
</Dialog>
);
+6 -4
View File
@@ -363,15 +363,17 @@ export function UnifiedToast(props: ToastProps) {
</>
)}
{action &&
"onClick" in (action as any) &&
"label" in (action as any) && (
"onClick" in (action as { onClick?: () => void; label?: string }) &&
"label" in (action as { onClick?: () => void; label?: string }) && (
<div className="mt-2 w-full">
<RippleButton
size="sm"
className="ml-auto"
onClick={(action as any).onClick}
onClick={
(action as { onClick: () => void; label: string }).onClick
}
>
{(action as any).label}
{(action as { onClick: () => void; label: string }).label}
</RippleButton>
</div>
)}
+4 -2
View File
@@ -40,11 +40,13 @@ function DataTableActionBar<TData>({
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [table]);
const portalContainer =
portalContainerProp ?? (mounted ? globalThis.document?.body : null);
portalContainerProp ?? (mounted ? globalThis.document.body : null);
if (!portalContainer) return null;
+3 -3
View File
@@ -148,9 +148,9 @@ export function DeleteGroupDialog({
<Label>What should happen to these profiles?</Label>
<RadioGroup
value={deleteAction}
onValueChange={(value) =>
setDeleteAction(value as "move" | "delete")
}
onValueChange={(value) => {
setDeleteAction(value as "move" | "delete");
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="move" id="move" />
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuRefreshCw } from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface BlocklistCacheStatus {
level: string;
display_name: string;
entry_count: number;
file_size_bytes: number;
last_updated: number | null;
is_fresh: boolean;
is_cached: boolean;
}
interface DnsBlocklistDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function DnsBlocklistDialog({
isOpen,
onClose,
}: DnsBlocklistDialogProps) {
const { t } = useTranslation();
const [statuses, setStatuses] = useState<BlocklistCacheStatus[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const loadStatuses = useCallback(async () => {
try {
const result = await invoke<BlocklistCacheStatus[]>(
"get_dns_blocklist_cache_status",
);
setStatuses(result);
} catch (e) {
console.error("Failed to load blocklist status:", e);
}
}, []);
useEffect(() => {
if (isOpen) {
void loadStatuses();
}
}, [isOpen, loadStatuses]);
const handleRefreshAll = async () => {
setIsRefreshing(true);
try {
await invoke("refresh_dns_blocklists");
await loadStatuses();
} catch (e) {
console.error("Failed to refresh blocklists:", e);
} finally {
setIsRefreshing(false);
}
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const formatDate = (timestamp: number | null) => {
if (!timestamp) return t("dnsBlocklist.notCached");
return new Date(timestamp * 1000).toLocaleString();
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("dnsBlocklist.title")}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t("dnsBlocklist.settingsDescription")}
</p>
<div className="space-y-3">
{statuses.map((status) => (
<div
key={status.level}
className="flex items-center justify-between rounded-md border border-border p-3"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{status.display_name}
</span>
{status.is_cached ? (
status.is_fresh ? (
<Badge variant="default" className="text-[10px] px-1.5">
{t("dnsBlocklist.fresh")}
</Badge>
) : (
<Badge variant="secondary" className="text-[10px] px-1.5">
{t("dnsBlocklist.stale")}
</Badge>
)
) : (
<Badge
variant="outline"
className="text-[10px] px-1.5 text-muted-foreground"
>
{t("dnsBlocklist.notCached")}
</Badge>
)}
</div>
{status.is_cached && (
<div className="text-xs text-muted-foreground">
{status.entry_count.toLocaleString()}{" "}
{t("dnsBlocklist.domains")} &middot;{" "}
{formatSize(status.file_size_bytes)} &middot;{" "}
{formatDate(status.last_updated)}
</div>
)}
</div>
</div>
))}
</div>
<Button
onClick={handleRefreshAll}
disabled={isRefreshing}
variant="outline"
className="w-full"
>
<LuRefreshCw
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("dnsBlocklist.refreshAll")}
</Button>
</DialogContent>
</Dialog>
);
}
+3 -1
View File
@@ -90,7 +90,9 @@ export function EditGroupDialog({
id="group-name"
placeholder="Enter group name..."
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
onChange={(e) => {
setGroupName(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && groupName.trim()) {
void handleUpdate();
@@ -137,7 +137,7 @@ export function ExtensionGroupAssignmentDialog({
</div>
) : (
<Select
value={selectedGroupId || "none"}
value={selectedGroupId ?? "none"}
onValueChange={(value) => {
setSelectedGroupId(value === "none" ? null : value);
}}
+46 -26
View File
@@ -197,9 +197,7 @@ export function ExtensionManagementDialog({
useEffect(() => {
if (isOpen) {
void loadData().then(() => {
// Icons will be loaded after extensions are set
});
void loadData();
}
}, [isOpen, loadData]);
@@ -562,7 +560,9 @@ export function ExtensionManagementDialog({
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActiveTab("extensions")}
onClick={() => {
setActiveTab("extensions");
}}
disabled={limitedMode}
>
{t("extensions.extensionsTab")}
@@ -574,7 +574,9 @@ export function ExtensionManagementDialog({
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActiveTab("groups")}
onClick={() => {
setActiveTab("groups");
}}
disabled={limitedMode}
>
{t("extensions.groupsTab")}
@@ -627,13 +629,15 @@ export function ExtensionManagementDialog({
<div className="flex gap-2">
<Input
value={extensionName}
onChange={(e) => setExtensionName(e.target.value)}
onChange={(e) => {
setExtensionName(e.target.value);
}}
placeholder={t("extensions.namePlaceholder")}
className="flex-1"
/>
<RippleButton
size="sm"
onClick={handleUpload}
onClick={() => void handleUpload()}
disabled={isUploading || !extensionName.trim()}
>
{isUploading
@@ -705,7 +709,7 @@ export function ExtensionManagementDialog({
<Checkbox
checked={ext.sync_enabled}
onCheckedChange={() =>
handleToggleExtSync(ext)
void handleToggleExtSync(ext)
}
disabled={isTogglingExtSync[ext.id]}
/>
@@ -745,7 +749,9 @@ export function ExtensionManagementDialog({
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => setExtensionToDelete(ext)}
onClick={() => {
setExtensionToDelete(ext);
}}
>
<LuTrash2 className="w-3.5 h-3.5" />
</Button>
@@ -769,7 +775,9 @@ export function ExtensionManagementDialog({
<Label>{t("extensions.groupsTab")}</Label>
<RippleButton
size="sm"
onClick={() => setShowCreateGroup(true)}
onClick={() => {
setShowCreateGroup(true);
}}
className="flex gap-2 items-center"
disabled={limitedMode}
>
@@ -783,7 +791,9 @@ export function ExtensionManagementDialog({
<div className="flex gap-2 items-center">
<Input
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
onChange={(e) => {
setNewGroupName(e.target.value);
}}
placeholder={t("extensions.groupNamePlaceholder")}
className="flex-1"
onKeyDown={(e) => {
@@ -792,7 +802,7 @@ export function ExtensionManagementDialog({
/>
<RippleButton
size="sm"
onClick={handleCreateGroup}
onClick={() => void handleCreateGroup()}
disabled={!newGroupName.trim()}
>
{t("common.buttons.create")}
@@ -902,7 +912,7 @@ export function ExtensionManagementDialog({
<Checkbox
checked={group.sync_enabled}
onCheckedChange={() =>
handleToggleGroupSync(group)
void handleToggleGroupSync(group)
}
disabled={isTogglingGroupSync[group.id]}
/>
@@ -943,7 +953,9 @@ export function ExtensionManagementDialog({
<Button
variant="ghost"
size="sm"
onClick={() => setGroupToDelete(group)}
onClick={() => {
setGroupToDelete(group);
}}
>
<LuTrash2 className="w-4 h-4" />
</Button>
@@ -996,7 +1008,9 @@ export function ExtensionManagementDialog({
<Label>{t("common.labels.name")}</Label>
<Input
value={editGroupName}
onChange={(e) => setEditGroupName(e.target.value)}
onChange={(e) => {
setEditGroupName(e.target.value);
}}
placeholder={t("extensions.groupNamePlaceholder")}
/>
</div>
@@ -1007,9 +1021,9 @@ export function ExtensionManagementDialog({
<Label>{t("extensions.addToGroup")}</Label>
<Select
value=""
onValueChange={(extId) =>
setEditGroupExtensionIds((prev) => [...prev, extId])
}
onValueChange={(extId) => {
setEditGroupExtensionIds((prev) => [...prev, extId]);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("extensions.addToGroup")} />
@@ -1055,11 +1069,11 @@ export function ExtensionManagementDialog({
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
onClick={() =>
onClick={() => {
setEditGroupExtensionIds((prev) =>
prev.filter((id) => id !== extId),
)
}
);
}}
>
<LuTrash2 className="w-3 h-3" />
</Button>
@@ -1083,7 +1097,7 @@ export function ExtensionManagementDialog({
{t("common.buttons.cancel")}
</Button>
<RippleButton
onClick={handleSaveGroupEdits}
onClick={() => void handleSaveGroupEdits()}
disabled={!editGroupName.trim()}
>
{t("common.buttons.save")}
@@ -1117,7 +1131,9 @@ export function ExtensionManagementDialog({
<Label>{t("common.labels.name")}</Label>
<Input
value={editExtensionName}
onChange={(e) => setEditExtensionName(e.target.value)}
onChange={(e) => {
setEditExtensionName(e.target.value);
}}
placeholder={t("extensions.namePlaceholder")}
onKeyDown={(e) => {
if (e.key === "Enter") void handleUpdateExtension();
@@ -1239,7 +1255,7 @@ export function ExtensionManagementDialog({
{t("common.buttons.cancel")}
</Button>
<RippleButton
onClick={handleUpdateExtension}
onClick={() => void handleUpdateExtension()}
disabled={!editExtensionName.trim()}
>
{t("common.buttons.save")}
@@ -1251,7 +1267,9 @@ export function ExtensionManagementDialog({
{/* Delete extension confirmation */}
<DeleteConfirmationDialog
isOpen={extensionToDelete !== null}
onClose={() => setExtensionToDelete(null)}
onClose={() => {
setExtensionToDelete(null);
}}
onConfirm={handleDeleteExtension}
title={t("extensions.deleteConfirmTitle")}
description={t("extensions.deleteConfirmDescription", {
@@ -1263,7 +1281,9 @@ export function ExtensionManagementDialog({
{/* Delete group confirmation */}
<DeleteConfirmationDialog
isOpen={groupToDelete !== null}
onClose={() => setGroupToDelete(null)}
onClose={() => {
setGroupToDelete(null);
}}
onConfirm={handleDeleteGroup}
title={t("extensions.deleteGroupConfirmTitle")}
description={t("extensions.deleteGroupConfirmDescription", {
+7 -3
View File
@@ -144,7 +144,9 @@ export function GroupAssignmentDialog({
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => setCreateDialogOpen(true)}
onClick={() => {
setCreateDialogOpen(true);
}}
>
<GoPlus className="mr-1 w-3 h-3" /> Create Group
</RippleButton>
@@ -155,7 +157,7 @@ export function GroupAssignmentDialog({
</div>
) : (
<Select
value={selectedGroupId || "default"}
value={selectedGroupId ?? "default"}
onValueChange={(value) => {
setSelectedGroupId(value === "default" ? null : value);
}}
@@ -201,7 +203,9 @@ export function GroupAssignmentDialog({
</DialogContent>
<CreateGroupDialog
isOpen={createDialogOpen}
onClose={() => setCreateDialogOpen(false)}
onClose={() => {
setCreateDialogOpen(false);
}}
onGroupCreated={(group) => {
setGroups((prev) => [...prev, group]);
setSelectedGroupId(group.id);
+18 -6
View File
@@ -246,7 +246,9 @@ export function GroupManagementDialog({
<Label>Groups</Label>
<RippleButton
size="sm"
onClick={() => setCreateDialogOpen(true)}
onClick={() => {
setCreateDialogOpen(true);
}}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
@@ -350,7 +352,9 @@ export function GroupManagementDialog({
<Button
variant="ghost"
size="sm"
onClick={() => handleEditGroup(group)}
onClick={() => {
handleEditGroup(group);
}}
>
<LuPencil className="w-4 h-4" />
</Button>
@@ -364,7 +368,9 @@ export function GroupManagementDialog({
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteGroup(group)}
onClick={() => {
handleDeleteGroup(group);
}}
>
<LuTrash2 className="w-4 h-4" />
</Button>
@@ -395,20 +401,26 @@ export function GroupManagementDialog({
<CreateGroupDialog
isOpen={createDialogOpen}
onClose={() => setCreateDialogOpen(false)}
onClose={() => {
setCreateDialogOpen(false);
}}
onGroupCreated={handleGroupCreated}
/>
<EditGroupDialog
isOpen={editDialogOpen}
onClose={() => setEditDialogOpen(false)}
onClose={() => {
setEditDialogOpen(false);
}}
group={selectedGroup}
onGroupUpdated={handleGroupUpdated}
/>
<DeleteGroupDialog
isOpen={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
onClose={() => {
setDeleteDialogOpen(false);
}}
group={selectedGroup}
onGroupDeleted={handleGroupDeleted}
/>
+17 -7
View File
@@ -166,7 +166,7 @@ function useLogoEasterEgg() {
};
}
type Props = {
interface Props {
onSettingsDialogOpen: (open: boolean) => void;
onProxyManagementDialogOpen: (open: boolean) => void;
onGroupManagementDialogOpen: (open: boolean) => void;
@@ -177,7 +177,7 @@ type Props = {
onExtensionManagementDialogOpen: (open: boolean) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
};
}
const HomeHeader = ({
onSettingsDialogOpen,
@@ -211,9 +211,15 @@ const HomeHeader = ({
type="button"
className="p-1 cursor-pointer select-none"
onClick={handleClick}
onPointerDown={() => setIsPressed(true)}
onPointerUp={() => setIsPressed(false)}
onPointerLeave={() => setIsPressed(false)}
onPointerDown={() => {
setIsPressed(true);
}}
onPointerUp={() => {
setIsPressed(false);
}}
onPointerLeave={() => {
setIsPressed(false);
}}
>
<Logo
key={wobbleKey}
@@ -238,14 +244,18 @@ const HomeHeader = ({
type="text"
placeholder={t("header.searchPlaceholder")}
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
onChange={(e) => {
onSearchQueryChange(e.target.value);
}}
className="pr-8 pl-10 w-48"
/>
<LuSearch className="absolute left-3 top-1/2 w-4 h-4 transform -translate-y-1/2 text-muted-foreground" />
{searchQuery && (
<button
type="button"
onClick={() => onSearchQueryChange("")}
onClick={() => {
onSearchQueryChange("");
}}
className="absolute right-2 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
aria-label={t("header.clearSearch")}
>

Some files were not shown because too many files have changed in this diff Show More